690414:1113 Update README.md /.agents/skills, /.windsurf/workflows

This commit is contained in:
2026-04-14 11:13:42 +07:00
parent 02400fd88c
commit 6d45bdaeb5
194 changed files with 12708 additions and 8762 deletions
@@ -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)',