690414:1113 Update README.md /.agents/skills, /.windsurf/workflows
This commit is contained in:
@@ -8,13 +8,22 @@ import {
|
||||
ParseIntPipe,
|
||||
UseGuards,
|
||||
Patch,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
ApiBody,
|
||||
} from '@nestjs/swagger';
|
||||
import { CirculationService } from './circulation.service';
|
||||
import { CreateCirculationDto } from './dto/create-circulation.dto';
|
||||
import { UpdateCirculationRoutingDto } from './dto/update-circulation-routing.dto';
|
||||
import { SearchCirculationDto } from './dto/search-circulation.dto';
|
||||
import { ReassignRoutingDto } from './dto/reassign-routing.dto';
|
||||
import { ForceCloseCirculationDto } from './dto/force-close-circulation.dto';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
@@ -63,4 +72,43 @@ export class CirculationController {
|
||||
) {
|
||||
return this.circulationService.updateRoutingStatus(id, updateDto, user);
|
||||
}
|
||||
|
||||
@Patch(':uuid/routing/:routingId/reassign')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Re-assign routing to new user when assignee is deactivated (EC-CIRC-001)',
|
||||
})
|
||||
@ApiParam({ name: 'uuid', description: 'Circulation publicId' })
|
||||
@ApiParam({ name: 'routingId', description: 'CirculationRouting INT id' })
|
||||
@ApiBody({ type: ReassignRoutingDto })
|
||||
@RequirePermission('circulation.manage')
|
||||
@Audit('circulation.reassign', 'circulation')
|
||||
reassignRouting(
|
||||
@Param('routingId', ParseIntPipe) routingId: number,
|
||||
@Body() dto: ReassignRoutingDto,
|
||||
@CurrentUser() user: User
|
||||
) {
|
||||
return this.circulationService.reassignRouting(
|
||||
routingId,
|
||||
dto.newAssigneeId,
|
||||
user
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':uuid/force-close')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Force close a Circulation with mandatory reason (EC-CIRC-002)',
|
||||
})
|
||||
@ApiParam({ name: 'uuid', description: 'Circulation publicId' })
|
||||
@ApiBody({ type: ForceCloseCirculationDto })
|
||||
@RequirePermission('circulation.manage')
|
||||
@Audit('circulation.force_close', 'circulation')
|
||||
forceClose(
|
||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||
@Body() dto: ForceCloseCirculationDto,
|
||||
@CurrentUser() user: User
|
||||
) {
|
||||
return this.circulationService.forceClose(uuid, dto.reason, user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { CirculationService } from './circulation.service';
|
||||
import { Circulation } from './entities/circulation.entity';
|
||||
import { CirculationRouting } from './entities/circulation-routing.entity';
|
||||
import { CirculationStatusCode } from './entities/circulation-status-code.entity';
|
||||
import { DocumentNumberingService } from '../document-numbering/services/document-numbering.service';
|
||||
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
|
||||
import {
|
||||
ValidationException,
|
||||
NotFoundException,
|
||||
} from '../../common/exceptions';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
|
||||
describe('CirculationService', () => {
|
||||
let service: CirculationService;
|
||||
let circulationRepo: { findOne: jest.Mock; save: jest.Mock };
|
||||
let routingRepo: { findOne: jest.Mock; save: jest.Mock };
|
||||
let dataSource: { createQueryRunner: jest.Mock };
|
||||
let uuidResolver: { resolveUserId: jest.Mock };
|
||||
let workflowEngine: { getInstanceByEntity: jest.Mock };
|
||||
|
||||
const mockUser: Partial<User> = { user_id: 1, username: 'admin' };
|
||||
|
||||
const mockQueryRunner = {
|
||||
connect: jest.fn(),
|
||||
startTransaction: jest.fn(),
|
||||
commitTransaction: jest.fn(),
|
||||
rollbackTransaction: jest.fn(),
|
||||
release: jest.fn(),
|
||||
manager: { save: jest.fn() },
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
circulationRepo = { findOne: jest.fn(), save: jest.fn() };
|
||||
routingRepo = { findOne: jest.fn(), save: jest.fn() };
|
||||
uuidResolver = { resolveUserId: jest.fn() };
|
||||
workflowEngine = { getInstanceByEntity: jest.fn() };
|
||||
dataSource = { createQueryRunner: jest.fn(() => mockQueryRunner) };
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CirculationService,
|
||||
{ provide: getRepositoryToken(Circulation), useValue: circulationRepo },
|
||||
{
|
||||
provide: getRepositoryToken(CirculationRouting),
|
||||
useValue: routingRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(CirculationStatusCode),
|
||||
useValue: { findOne: jest.fn() },
|
||||
},
|
||||
{ provide: DataSource, useValue: dataSource },
|
||||
{ provide: DocumentNumberingService, useValue: {} },
|
||||
{ provide: UuidResolverService, useValue: uuidResolver },
|
||||
{
|
||||
provide: UserService,
|
||||
useValue: { getUserPermissions: jest.fn().mockResolvedValue([]) },
|
||||
},
|
||||
{ provide: WorkflowEngineService, useValue: workflowEngine },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<CirculationService>(CirculationService);
|
||||
});
|
||||
|
||||
describe('reassignRouting() - EC-CIRC-001', () => {
|
||||
it('reassigns a PENDING routing to a new user by UUID', async () => {
|
||||
const mockRouting = {
|
||||
id: 5,
|
||||
status: 'PENDING',
|
||||
assignedTo: 10,
|
||||
circulation: {},
|
||||
};
|
||||
routingRepo.findOne.mockResolvedValue(mockRouting);
|
||||
uuidResolver.resolveUserId.mockResolvedValue(99);
|
||||
routingRepo.save.mockResolvedValue({ ...mockRouting, assignedTo: 99 });
|
||||
|
||||
const result = await service.reassignRouting(
|
||||
5,
|
||||
'new-user-uuid',
|
||||
mockUser as User
|
||||
);
|
||||
|
||||
expect(uuidResolver.resolveUserId).toHaveBeenCalledWith('new-user-uuid');
|
||||
expect(routingRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ assignedTo: 99 })
|
||||
);
|
||||
expect(result.assignedTo).toBe(99);
|
||||
});
|
||||
|
||||
it('throws ValidationException when routing is not in PENDING status', async () => {
|
||||
routingRepo.findOne.mockResolvedValue({
|
||||
id: 5,
|
||||
status: 'COMPLETED',
|
||||
circulation: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.reassignRouting(5, 'new-user-uuid', mockUser as User)
|
||||
).rejects.toThrow(ValidationException);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when routing does not exist', async () => {
|
||||
routingRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.reassignRouting(999, 'new-user-uuid', mockUser as User)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forceClose() - EC-CIRC-002', () => {
|
||||
const uuid = '019circ-0000-7000-8000-000000000001';
|
||||
|
||||
const buildMockCirculation = () => ({
|
||||
id: 100,
|
||||
publicId: uuid,
|
||||
circulationNo: 'CIRC-2026-001',
|
||||
statusCode: 'OPEN',
|
||||
routings: [
|
||||
{ id: 1, status: 'PENDING', comments: null, completedAt: null },
|
||||
{
|
||||
id: 2,
|
||||
status: 'COMPLETED',
|
||||
comments: 'done',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
{ id: 3, status: 'IN_PROGRESS', comments: null, completedAt: null },
|
||||
],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
circulationRepo.findOne.mockResolvedValue(buildMockCirculation());
|
||||
});
|
||||
|
||||
it('saves rejected routings and commits the transaction', async () => {
|
||||
await service.forceClose(uuid, 'Budget cut', mockUser as User);
|
||||
|
||||
expect(mockQueryRunner.manager.save).toHaveBeenCalledTimes(3);
|
||||
expect(mockQueryRunner.commitTransaction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns success=true and affectedRoutings count of 2', async () => {
|
||||
const result = await service.forceClose(
|
||||
uuid,
|
||||
'Cost savings',
|
||||
mockUser as User
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.affectedRoutings).toBe(2);
|
||||
});
|
||||
|
||||
it('throws ValidationException when reason is an empty string', async () => {
|
||||
await expect(
|
||||
service.forceClose(uuid, '', mockUser as User)
|
||||
).rejects.toThrow(ValidationException);
|
||||
});
|
||||
|
||||
it('throws ValidationException when reason is only whitespace', async () => {
|
||||
await expect(
|
||||
service.forceClose(uuid, ' ', mockUser as User)
|
||||
).rejects.toThrow(ValidationException);
|
||||
});
|
||||
|
||||
it('throws ValidationException when circulation is already COMPLETED', async () => {
|
||||
circulationRepo.findOne.mockResolvedValue({
|
||||
...buildMockCirculation(),
|
||||
statusCode: 'COMPLETED',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.forceClose(uuid, 'Trying to close completed', mockUser as User)
|
||||
).rejects.toThrow(ValidationException);
|
||||
});
|
||||
|
||||
it('throws ValidationException when circulation is already CANCELLED', async () => {
|
||||
circulationRepo.findOne.mockResolvedValue({
|
||||
...buildMockCirculation(),
|
||||
statusCode: 'CANCELLED',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.forceClose(uuid, 'Already cancelled', mockUser as User)
|
||||
).rejects.toThrow(ValidationException);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when circulation is not found', async () => {
|
||||
circulationRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.forceClose(uuid, 'Not found', mockUser as User)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOneByUuid() - EC-CIRC-003 workflowInstanceId + deadlineDate', () => {
|
||||
it('exposes workflowInstanceId and deadlineDate when a workflow instance exists', async () => {
|
||||
circulationRepo.findOne.mockResolvedValue({
|
||||
id: 100,
|
||||
publicId: '019circ-test-uuid',
|
||||
circulationNo: 'CIRC-001',
|
||||
subject: 'Test',
|
||||
statusCode: 'OPEN',
|
||||
routings: [],
|
||||
deadlineDate: '2026-04-20',
|
||||
});
|
||||
workflowEngine.getInstanceByEntity.mockResolvedValue({
|
||||
id: 'wf-circ-uuid-001',
|
||||
currentState: 'OPEN',
|
||||
availableActions: [],
|
||||
});
|
||||
|
||||
const result = await service.findOneByUuid('019circ-test-uuid');
|
||||
|
||||
expect(workflowEngine.getInstanceByEntity).toHaveBeenCalledWith(
|
||||
'circulation',
|
||||
'100'
|
||||
);
|
||||
expect(result.workflowInstanceId).toBe('wf-circ-uuid-001');
|
||||
expect(result.workflowState).toBe('OPEN');
|
||||
expect((result as { deadlineDate?: string }).deadlineDate).toBe(
|
||||
'2026-04-20'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty availableActions and undefined workflowInstanceId in draft state', async () => {
|
||||
circulationRepo.findOne.mockResolvedValue({
|
||||
id: 101,
|
||||
publicId: '019circ-draft-uuid',
|
||||
circulationNo: 'CIRC-002',
|
||||
statusCode: 'DRAFT',
|
||||
routings: [],
|
||||
});
|
||||
workflowEngine.getInstanceByEntity.mockResolvedValue(null);
|
||||
|
||||
const result = await service.findOneByUuid('019circ-draft-uuid');
|
||||
|
||||
expect(result.workflowInstanceId).toBeUndefined();
|
||||
expect(result.availableActions).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
NotFoundException,
|
||||
PermissionException,
|
||||
@@ -16,9 +16,12 @@ import { SearchCirculationDto } from './dto/search-circulation.dto';
|
||||
import { DocumentNumberingService } from '../document-numbering/services/document-numbering.service';
|
||||
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
|
||||
|
||||
@Injectable()
|
||||
export class CirculationService {
|
||||
private readonly logger = new Logger(CirculationService.name);
|
||||
|
||||
private async hasSystemManageAllPermission(userId: number): Promise<boolean> {
|
||||
const permissions = await this.userService.getUserPermissions(userId);
|
||||
return permissions.includes('system.manage_all');
|
||||
@@ -32,7 +35,8 @@ export class CirculationService {
|
||||
private numberingService: DocumentNumberingService,
|
||||
private dataSource: DataSource,
|
||||
private uuidResolver: UuidResolverService,
|
||||
private userService: UserService
|
||||
private userService: UserService,
|
||||
private workflowEngine: WorkflowEngineService
|
||||
) {}
|
||||
|
||||
async create(createDto: CreateCirculationDto, user: User) {
|
||||
@@ -184,7 +188,115 @@ export class CirculationService {
|
||||
});
|
||||
if (!circulation)
|
||||
throw new NotFoundException(`Circulation publicId ${publicId} not found`);
|
||||
return circulation;
|
||||
|
||||
// v1.8.7: ดึง Workflow Instance สำหรับ Circulation นี้ (nullable — ก่อน Submit ไม่มี Instance)
|
||||
const wfInstance = await this.workflowEngine.getInstanceByEntity(
|
||||
'circulation',
|
||||
circulation.id.toString()
|
||||
);
|
||||
|
||||
return {
|
||||
...circulation,
|
||||
workflowInstanceId: wfInstance?.id,
|
||||
workflowState: wfInstance?.currentState,
|
||||
availableActions: wfInstance?.availableActions ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* EC-CIRC-001: Re-assign routing เมื่อ Assignee ถูก Deactivate (v1.8.7)
|
||||
* ต้องมีสิทธิ์ circulation.manage
|
||||
*/
|
||||
async reassignRouting(
|
||||
routingId: number,
|
||||
newAssigneePublicId: string,
|
||||
user: User
|
||||
) {
|
||||
const routing = await this.routingRepo.findOne({
|
||||
where: { id: routingId },
|
||||
relations: ['circulation'],
|
||||
});
|
||||
if (!routing)
|
||||
throw new NotFoundException('Circulation Routing', String(routingId));
|
||||
|
||||
if (routing.status !== 'PENDING') {
|
||||
throw new ValidationException(
|
||||
`Routing ID ${routingId} ไม่ได้อยู่ใน PENDING จึงไม่สามารถ Re-assign ได้`
|
||||
);
|
||||
}
|
||||
|
||||
const newAssigneeId =
|
||||
await this.uuidResolver.resolveUserId(newAssigneePublicId);
|
||||
routing.assignedTo = newAssigneeId;
|
||||
const saved = await this.routingRepo.save(routing);
|
||||
|
||||
this.logger.log(
|
||||
`Circulation routing ${routingId} reassigned to user ${newAssigneeId} by ${user.user_id}`
|
||||
);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* EC-CIRC-002: Force Close Circulation พร้อม reason บังคับ (v1.8.7)
|
||||
* ปิด routing ที่ PENDING ทั้งหมด + เปลี่ยน statusCode เป็น CANCELLED
|
||||
* ต้องมีสิทธิ์ circulation.manage
|
||||
*/
|
||||
async forceClose(publicId: string, reason: string, user: User) {
|
||||
if (!reason || reason.trim().length === 0) {
|
||||
throw new ValidationException('กรุณาระบุเหตุผลในการปิดใบเวียนแบบบังคับ');
|
||||
}
|
||||
|
||||
const circulation = await this.circulationRepo.findOne({
|
||||
where: { publicId },
|
||||
relations: ['routings'],
|
||||
});
|
||||
if (!circulation)
|
||||
throw new NotFoundException(`Circulation publicId ${publicId}`);
|
||||
|
||||
if (
|
||||
circulation.statusCode === 'COMPLETED' ||
|
||||
circulation.statusCode === 'CANCELLED'
|
||||
) {
|
||||
throw new ValidationException(
|
||||
`ใบเวียน ${circulation.circulationNo} ปิดไปแล้ว (${circulation.statusCode})`
|
||||
);
|
||||
}
|
||||
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// ปิด routing ที่ยัง PENDING ทั้งหมด
|
||||
const pendingRoutings = circulation.routings.filter(
|
||||
(r) => r.status === 'PENDING' || r.status === 'IN_PROGRESS'
|
||||
);
|
||||
for (const routing of pendingRoutings) {
|
||||
routing.status = 'REJECTED';
|
||||
routing.comments = `Force closed by user ${user.user_id}: ${reason}`;
|
||||
routing.completedAt = new Date();
|
||||
await queryRunner.manager.save(routing);
|
||||
}
|
||||
|
||||
// อัปเดตสถานะ Circulation เป็น CANCELLED
|
||||
circulation.statusCode = 'CANCELLED';
|
||||
circulation.closedAt = new Date();
|
||||
await queryRunner.manager.save(circulation);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
this.logger.log(
|
||||
`Circulation ${publicId} force-closed by user ${user.user_id}. Reason: ${reason}`
|
||||
);
|
||||
return { success: true, affectedRoutings: pendingRoutings.length };
|
||||
} catch (err) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
this.logger.error(
|
||||
`Force close failed for ${publicId}: ${(err as Error).message}`
|
||||
);
|
||||
throw err;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Logic อัปเดตสถานะและปิดงาน
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsString, IsNotEmpty, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ForceCloseCirculationDto {
|
||||
@ApiProperty({ description: 'เหตุผลการปิดใบเวียนแบบบังคับ (บังคับกรอก)' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(5)
|
||||
reason!: string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ReassignRoutingDto {
|
||||
@ApiProperty({
|
||||
description: 'publicId (UUID) ของผู้ใช้คนใหม่ที่ได้รับมอบหมาย',
|
||||
})
|
||||
@IsUUID('all')
|
||||
@IsNotEmpty()
|
||||
newAssigneeId!: string;
|
||||
}
|
||||
@@ -46,6 +46,9 @@ export class Circulation extends UuidBaseEntity {
|
||||
@Column({ name: 'closed_at', type: 'timestamp', nullable: true })
|
||||
closedAt?: Date;
|
||||
|
||||
@Column({ name: 'deadline_date', type: 'date', nullable: true })
|
||||
deadlineDate?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user