690414:1113 Update README.md /.agents/skills, /.windsurf/workflows
This commit is contained in:
@@ -6,6 +6,8 @@ import {
|
||||
Param,
|
||||
UseGuards,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { TransmittalService } from './transmittal.service';
|
||||
import { CreateTransmittalDto } from './dto/create-transmittal.dto';
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
} from '@nestjs/swagger';
|
||||
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
|
||||
import { ProjectService } from '../project/project.service';
|
||||
import { Audit } from '../../common/decorators/audit.decorator';
|
||||
|
||||
@ApiTags('Transmittals')
|
||||
@ApiBearerAuth()
|
||||
@@ -61,11 +64,29 @@ export class TransmittalController {
|
||||
@Get(':uuid')
|
||||
@ApiOperation({ summary: 'Get Transmittal details' })
|
||||
@ApiParam({
|
||||
name: 'publicId',
|
||||
name: 'uuid',
|
||||
description: 'Transmittal publicId (from correspondences.publicId)',
|
||||
})
|
||||
@RequirePermission('document.view')
|
||||
findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
|
||||
return this.transmittalService.findOneByUuid(uuid);
|
||||
}
|
||||
|
||||
@Post(':uuid/submit')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Submit Transmittal to Workflow (with EC-RFA-004 validation)',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'uuid',
|
||||
description: 'Transmittal publicId (from correspondences.publicId)',
|
||||
})
|
||||
@RequirePermission('document.manage')
|
||||
@Audit('transmittal.submit', 'transmittal')
|
||||
submit(
|
||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||
@CurrentUser() user: User
|
||||
) {
|
||||
return this.transmittalService.submit(uuid, user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ import { TransmittalItem } from './entities/transmittal-item.entity';
|
||||
import { Correspondence } from '../correspondence/entities/correspondence.entity';
|
||||
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
|
||||
import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity';
|
||||
import { TransmittalService } from './transmittal.service';
|
||||
import { TransmittalController } from './transmittal.controller';
|
||||
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
|
||||
import { ProjectModule } from '../project/project.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { SearchModule } from '../search/search.module';
|
||||
import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -20,11 +22,13 @@ import { SearchModule } from '../search/search.module';
|
||||
Correspondence,
|
||||
CorrespondenceType,
|
||||
CorrespondenceStatus,
|
||||
CorrespondenceRevision,
|
||||
]),
|
||||
DocumentNumberingModule,
|
||||
ProjectModule,
|
||||
UserModule,
|
||||
SearchModule,
|
||||
WorkflowEngineModule,
|
||||
],
|
||||
controllers: [TransmittalController],
|
||||
providers: [TransmittalService],
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { TransmittalService } from './transmittal.service';
|
||||
import { Transmittal } from './entities/transmittal.entity';
|
||||
import { TransmittalItem } from './entities/transmittal-item.entity';
|
||||
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
|
||||
import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.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('TransmittalService', () => {
|
||||
let service: TransmittalService;
|
||||
let transmittalRepo: { findOne: jest.Mock };
|
||||
let revisionRepo: {
|
||||
findOne: jest.Mock;
|
||||
createQueryBuilder: jest.Mock;
|
||||
save: jest.Mock;
|
||||
};
|
||||
let statusRepo: { findOne: jest.Mock };
|
||||
let dataSource: {
|
||||
manager: { findOne: jest.Mock };
|
||||
createQueryRunner: jest.Mock;
|
||||
};
|
||||
let workflowEngine: {
|
||||
getInstanceByEntity: jest.Mock;
|
||||
createInstance: jest.Mock;
|
||||
processTransition: jest.Mock;
|
||||
};
|
||||
|
||||
const mockUser: Partial<User> = {
|
||||
user_id: 1,
|
||||
username: 'testuser',
|
||||
primaryOrganizationId: 10,
|
||||
};
|
||||
|
||||
const mockTransmittal = {
|
||||
correspondenceId: 99,
|
||||
items: [{ itemCorrespondenceId: 201 }, { itemCorrespondenceId: 202 }],
|
||||
};
|
||||
|
||||
const mockQB = {
|
||||
innerJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn(),
|
||||
};
|
||||
|
||||
const mockQueryRunner = {
|
||||
connect: jest.fn(),
|
||||
startTransaction: jest.fn(),
|
||||
commitTransaction: jest.fn(),
|
||||
rollbackTransaction: jest.fn(),
|
||||
release: jest.fn(),
|
||||
manager: { save: jest.fn() },
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
transmittalRepo = { findOne: jest.fn() };
|
||||
revisionRepo = {
|
||||
findOne: jest.fn(),
|
||||
createQueryBuilder: jest.fn(() => mockQB),
|
||||
save: jest.fn(),
|
||||
};
|
||||
statusRepo = { findOne: jest.fn() };
|
||||
dataSource = {
|
||||
manager: { findOne: jest.fn() },
|
||||
createQueryRunner: jest.fn(() => mockQueryRunner),
|
||||
};
|
||||
workflowEngine = {
|
||||
getInstanceByEntity: jest.fn(),
|
||||
createInstance: jest.fn(),
|
||||
processTransition: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
TransmittalService,
|
||||
{ provide: getRepositoryToken(Transmittal), useValue: transmittalRepo },
|
||||
{
|
||||
provide: getRepositoryToken(TransmittalItem),
|
||||
useValue: { find: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(CorrespondenceType),
|
||||
useValue: { findOne: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(CorrespondenceStatus),
|
||||
useValue: statusRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(CorrespondenceRevision),
|
||||
useValue: revisionRepo,
|
||||
},
|
||||
{ provide: DataSource, useValue: dataSource },
|
||||
{ provide: DocumentNumberingService, useValue: {} },
|
||||
{ provide: UuidResolverService, useValue: {} },
|
||||
{
|
||||
provide: UserService,
|
||||
useValue: { getUserPermissions: jest.fn().mockResolvedValue([]) },
|
||||
},
|
||||
{ provide: WorkflowEngineService, useValue: workflowEngine },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<TransmittalService>(TransmittalService);
|
||||
});
|
||||
|
||||
describe('submit() - EC-RFA-004', () => {
|
||||
const uuid = '019abc01-0000-7000-8000-000000000001';
|
||||
|
||||
beforeEach(() => {
|
||||
dataSource.manager.findOne.mockResolvedValue({
|
||||
id: 99,
|
||||
correspondenceNumber: 'TRN-2026-001',
|
||||
});
|
||||
transmittalRepo.findOne.mockResolvedValue(mockTransmittal);
|
||||
});
|
||||
|
||||
it('throws ValidationException when an item correspondence is in DRAFT state (EC-RFA-004)', async () => {
|
||||
mockQB.getMany.mockResolvedValue([
|
||||
{ correspondence: { correspondenceNumber: 'RFA-2026-001' } },
|
||||
]);
|
||||
|
||||
await expect(service.submit(uuid, mockUser as User)).rejects.toThrow(
|
||||
ValidationException
|
||||
);
|
||||
});
|
||||
|
||||
it('includes the draft document number in the error response', async () => {
|
||||
mockQB.getMany.mockResolvedValue([
|
||||
{ correspondence: { correspondenceNumber: 'RFA-2026-001' } },
|
||||
]);
|
||||
|
||||
let thrownError: unknown;
|
||||
try {
|
||||
await service.submit(uuid, mockUser as User);
|
||||
} catch (e) {
|
||||
thrownError = e;
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(ValidationException);
|
||||
const res = (thrownError as ValidationException).getResponse();
|
||||
const resStr = typeof res === 'string' ? res : JSON.stringify(res);
|
||||
expect(resStr).toContain('RFA-2026-001');
|
||||
});
|
||||
|
||||
it('creates a workflow instance when no items are in DRAFT state', async () => {
|
||||
mockQB.getMany.mockResolvedValue([]);
|
||||
workflowEngine.createInstance.mockResolvedValue({
|
||||
id: 'wf-instance-uuid-001',
|
||||
});
|
||||
workflowEngine.processTransition.mockResolvedValue({
|
||||
nextState: 'IN_REVIEW',
|
||||
});
|
||||
revisionRepo.findOne.mockResolvedValue({
|
||||
id: 55,
|
||||
correspondenceId: 99,
|
||||
isCurrent: true,
|
||||
statusId: 1,
|
||||
});
|
||||
statusRepo.findOne
|
||||
.mockResolvedValueOnce({ id: 1, statusCode: 'DRAFT' })
|
||||
.mockResolvedValueOnce({ id: 2, statusCode: 'SUBMITTED' });
|
||||
|
||||
const result = await service.submit(uuid, mockUser as User);
|
||||
|
||||
expect(workflowEngine.createInstance).toHaveBeenCalledWith(
|
||||
'TRANSMITTAL_FLOW_V1',
|
||||
'transmittal',
|
||||
'99',
|
||||
expect.objectContaining({ ownerId: 1 })
|
||||
);
|
||||
expect(result).toEqual({
|
||||
instanceId: 'wf-instance-uuid-001',
|
||||
currentState: 'IN_REVIEW',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws NotFoundException when correspondence publicId is not found', async () => {
|
||||
dataSource.manager.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.submit(uuid, mockUser as User)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when transmittal record is not found', async () => {
|
||||
transmittalRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.submit(uuid, mockUser as User)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOneByUuid() - workflowInstanceId exposure (ADR-021)', () => {
|
||||
const uuid = '019abc02-0000-7000-8000-000000000002';
|
||||
|
||||
it('returns workflowInstanceId and workflowState when a workflow instance exists', async () => {
|
||||
dataSource.manager.findOne.mockResolvedValue({ id: 99 });
|
||||
transmittalRepo.findOne.mockResolvedValue({
|
||||
correspondenceId: 99,
|
||||
transmittalNo: 'TRN-001',
|
||||
subject: 'Test',
|
||||
correspondence: {
|
||||
id: 99,
|
||||
publicId: uuid,
|
||||
correspondenceNumber: 'TRN-001',
|
||||
},
|
||||
items: [],
|
||||
});
|
||||
workflowEngine.getInstanceByEntity.mockResolvedValue({
|
||||
id: 'wf-uuid-123',
|
||||
currentState: 'IN_REVIEW',
|
||||
availableActions: ['APPROVE', 'REJECT'],
|
||||
});
|
||||
|
||||
const result = await service.findOneByUuid(uuid);
|
||||
|
||||
expect(workflowEngine.getInstanceByEntity).toHaveBeenCalledWith(
|
||||
'transmittal',
|
||||
'99'
|
||||
);
|
||||
expect(result.workflowInstanceId).toBe('wf-uuid-123');
|
||||
expect(result.workflowState).toBe('IN_REVIEW');
|
||||
expect(result.availableActions).toEqual(['APPROVE', 'REJECT']);
|
||||
});
|
||||
|
||||
it('returns undefined workflowInstanceId when no workflow instance exists (Draft state)', async () => {
|
||||
dataSource.manager.findOne.mockResolvedValue({ id: 99 });
|
||||
transmittalRepo.findOne.mockResolvedValue({
|
||||
correspondenceId: 99,
|
||||
transmittalNo: 'TRN-001',
|
||||
items: [],
|
||||
correspondence: {
|
||||
id: 99,
|
||||
publicId: uuid,
|
||||
correspondenceNumber: 'TRN-001',
|
||||
},
|
||||
});
|
||||
workflowEngine.getInstanceByEntity.mockResolvedValue(null);
|
||||
|
||||
const result = await service.findOneByUuid(uuid);
|
||||
|
||||
expect(result.workflowInstanceId).toBeUndefined();
|
||||
expect(result.workflowState).toBeUndefined();
|
||||
expect(result.availableActions).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import { CorrespondenceStatus } from '../correspondence/entities/correspondence-
|
||||
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
|
||||
import { CorrespondenceRecipient } from '../correspondence/entities/correspondence-recipient.entity';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
|
||||
|
||||
@Injectable()
|
||||
export class TransmittalService {
|
||||
@@ -42,10 +43,13 @@ export class TransmittalService {
|
||||
private typeRepo: Repository<CorrespondenceType>,
|
||||
@InjectRepository(CorrespondenceStatus)
|
||||
private statusRepo: Repository<CorrespondenceStatus>,
|
||||
@InjectRepository(CorrespondenceRevision)
|
||||
private revisionRepo: Repository<CorrespondenceRevision>,
|
||||
private numberingService: DocumentNumberingService,
|
||||
private dataSource: DataSource,
|
||||
private uuidResolver: UuidResolverService,
|
||||
private userService: UserService
|
||||
private userService: UserService,
|
||||
private workflowEngine: WorkflowEngineService
|
||||
) {}
|
||||
|
||||
async create(
|
||||
@@ -192,9 +196,15 @@ export class TransmittalService {
|
||||
|
||||
/**
|
||||
* ADR-019: Find Transmittal by parent Correspondence publicId (public identifier).
|
||||
* Resolves correspondence.publicId → internal correspondenceId (INT)
|
||||
* v1.8.7: Exposes workflowInstanceId, workflowState, availableActions via WorkflowEngineService
|
||||
*/
|
||||
async findOneByUuid(publicId: string): Promise<Transmittal> {
|
||||
async findOneByUuid(publicId: string): Promise<
|
||||
Transmittal & {
|
||||
workflowInstanceId?: string;
|
||||
workflowState?: string;
|
||||
availableActions?: string[];
|
||||
}
|
||||
> {
|
||||
const correspondence = await this.dataSource.manager.findOne(
|
||||
Correspondence,
|
||||
{ where: { publicId }, select: ['id'] }
|
||||
@@ -204,7 +214,20 @@ export class TransmittalService {
|
||||
`Transmittal with publicId ${publicId} not found`
|
||||
);
|
||||
}
|
||||
return this.findOne(correspondence.id);
|
||||
const transmittal = await this.findOne(correspondence.id);
|
||||
|
||||
// v1.8.7: ดึง Workflow Instance สำหรับ Transmittal นี้ (nullable — Draft ไม่มี Instance)
|
||||
const wfInstance = await this.workflowEngine.getInstanceByEntity(
|
||||
'transmittal',
|
||||
correspondence.id.toString()
|
||||
);
|
||||
|
||||
return {
|
||||
...transmittal,
|
||||
workflowInstanceId: wfInstance?.id,
|
||||
workflowState: wfInstance?.currentState,
|
||||
availableActions: wfInstance?.availableActions ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: number): Promise<Transmittal> {
|
||||
@@ -217,6 +240,86 @@ export class TransmittalService {
|
||||
return transmittal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit Transmittal — ตรวจสอบ EC-RFA-004 ก่อนเริ่ม Workflow (v1.8.7)
|
||||
* EC-RFA-004: ทุก item ต้องไม่อยู่ใน DRAFT ก่อน Submit
|
||||
*/
|
||||
async submit(
|
||||
uuid: string,
|
||||
user: User
|
||||
): Promise<{ instanceId: string; currentState: string }> {
|
||||
const correspondence = await this.dataSource.manager.findOne(
|
||||
Correspondence,
|
||||
{ where: { publicId: uuid }, select: ['id', 'correspondenceNumber'] }
|
||||
);
|
||||
if (!correspondence)
|
||||
throw new NotFoundException(`Transmittal publicId ${uuid}`);
|
||||
|
||||
const transmittal = await this.transmittalRepo.findOne({
|
||||
where: { correspondenceId: correspondence.id },
|
||||
relations: ['items'],
|
||||
});
|
||||
if (!transmittal) throw new NotFoundException('Transmittal', uuid);
|
||||
|
||||
// EC-RFA-004: ตรวจสอบว่า item ทุกชิ้นไม่อยู่ใน DRAFT
|
||||
if (transmittal.items && transmittal.items.length > 0) {
|
||||
const itemCorrIds = transmittal.items.map((i) => i.itemCorrespondenceId);
|
||||
const draftRevisions = await this.revisionRepo
|
||||
.createQueryBuilder('rev')
|
||||
.innerJoin('rev.status', 'status')
|
||||
.where('rev.correspondenceId IN (:...ids)', { ids: itemCorrIds })
|
||||
.andWhere('rev.isCurrent = :isCurrent', { isCurrent: true })
|
||||
.andWhere('status.statusCode = :code', { code: 'DRAFT' })
|
||||
.leftJoinAndSelect('rev.correspondence', 'corr')
|
||||
.getMany();
|
||||
|
||||
if (draftRevisions.length > 0) {
|
||||
const draftDocNo =
|
||||
draftRevisions[0]?.correspondence?.correspondenceNumber ?? 'Unknown';
|
||||
throw new ValidationException(
|
||||
`RFA ${draftDocNo} ยังอยู่ใน Draft กรุณา Submit ก่อน`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// เริ่ม Workflow Instance สำหรับ Transmittal
|
||||
const statusDraft = await this.statusRepo.findOne({
|
||||
where: { statusCode: 'DRAFT' },
|
||||
});
|
||||
const instance = await this.workflowEngine.createInstance(
|
||||
'TRANSMITTAL_FLOW_V1',
|
||||
'transmittal',
|
||||
correspondence.id.toString(),
|
||||
{ ownerId: user.user_id }
|
||||
);
|
||||
|
||||
const result = await this.workflowEngine.processTransition(
|
||||
instance.id,
|
||||
'SUBMIT',
|
||||
user.user_id,
|
||||
'Transmittal Submitted'
|
||||
);
|
||||
|
||||
// Sync สถานะกลับที่ Correspondence Revision
|
||||
if (statusDraft) {
|
||||
const revision = await this.revisionRepo.findOne({
|
||||
where: { correspondenceId: correspondence.id, isCurrent: true },
|
||||
});
|
||||
if (revision) {
|
||||
const submittedStatus = await this.statusRepo.findOne({
|
||||
where: { statusCode: 'SUBMITTED' },
|
||||
});
|
||||
if (submittedStatus) {
|
||||
revision.statusId = submittedStatus.id;
|
||||
await this.revisionRepo.save(revision);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Transmittal ${uuid} submitted — instance ${instance.id}`);
|
||||
return { instanceId: instance.id, currentState: result.nextState };
|
||||
}
|
||||
|
||||
async findAll(query: SearchTransmittalDto) {
|
||||
const { page = 1, limit = 20, projectId, search } = query;
|
||||
const skip = ((page ?? 1) - 1) * (limit ?? 20);
|
||||
@@ -239,6 +342,13 @@ export class TransmittalService {
|
||||
});
|
||||
}
|
||||
|
||||
// B3: purpose filter (EC-RFA-004 aligned)
|
||||
if (query.purpose) {
|
||||
queryBuilder.andWhere('transmittal.purpose = :purpose', {
|
||||
purpose: query.purpose,
|
||||
});
|
||||
}
|
||||
|
||||
if (search) {
|
||||
queryBuilder.andWhere(
|
||||
'(correspondence.correspondenceNumber LIKE :search OR revision.title LIKE :search)',
|
||||
|
||||
Reference in New Issue
Block a user