690519:1631 224 to 226 AI #01
CI / CD Pipeline / build (push) Failing after 3m57s
CI / CD Pipeline / deploy (push) Has been skipped

This commit is contained in:
2026-05-19 16:31:50 +07:00
parent 3e25097470
commit ea5499123e
127 changed files with 12387 additions and 42 deletions
@@ -0,0 +1,152 @@
// File: src/modules/ai/tool/ai-tool-registry.service.spec.ts
// Change Log
// - 2026-05-19: สร้าง Unit Test สำหรับ AiToolRegistryService (ADR-025).
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AiToolRegistryService } from './ai-tool-registry.service';
import { RfaToolService } from './rfa-tool.service';
import { DrawingToolService } from './drawing-tool.service';
import { TransmittalToolService } from './transmittal-tool.service';
import { AiAuditLog, AiAuditStatus } from '../entities/ai-audit-log.entity';
import { ServerIntent } from './types/server-intent.enum';
import { ToolHandlerContext } from './types/tool-handler-context.type';
import { User } from '../../user/entities/user.entity';
/**
* Mock User สำหรับ Unit Test
* ไม่มี assignments → CASL deny ทุก action (ทดสอบ FORBIDDEN case)
*/
const mockUser = {
user_id: 1,
publicId: 'test-uuid-user',
assignments: [],
} as unknown as User;
/** Context มาตรฐานสำหรับ test */
const mockContext: ToolHandlerContext = {
requestUser: mockUser,
projectPublicId: '0195a1b2-c3d4-7000-8000-abc123def456',
};
const mockAuditLogRepo = {
create: jest.fn().mockReturnValue({}),
save: jest.fn().mockResolvedValue({}),
};
const mockRfaToolService = {
getRfa: jest
.fn()
.mockResolvedValue({ ok: true, data: [{ publicId: 'rfa-uuid' }] }),
};
const mockDrawingToolService = {
getDrawing: jest
.fn()
.mockResolvedValue({ ok: true, data: [{ publicId: 'drawing-uuid' }] }),
};
const mockTransmittalToolService = {
getTransmittal: jest
.fn()
.mockResolvedValue({ ok: true, data: [{ publicId: 'transmittal-uuid' }] }),
};
describe('AiToolRegistryService', () => {
let service: AiToolRegistryService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AiToolRegistryService,
{ provide: RfaToolService, useValue: mockRfaToolService },
{ provide: DrawingToolService, useValue: mockDrawingToolService },
{
provide: TransmittalToolService,
useValue: mockTransmittalToolService,
},
{
provide: getRepositoryToken(AiAuditLog),
useValue: mockAuditLogRepo,
},
],
}).compile();
service = module.get<AiToolRegistryService>(AiToolRegistryService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('getHandler()', () => {
it('ควรคืน handler สำหรับ GET_RFA', () => {
const handler = service.getHandler(ServerIntent.GET_RFA);
expect(handler).toBeDefined();
});
it('ควรคืน handler สำหรับ GET_DRAWING', () => {
const handler = service.getHandler(ServerIntent.GET_DRAWING);
expect(handler).toBeDefined();
});
it('ควรคืน handler สำหรับ GET_TRANSMITTAL', () => {
const handler = service.getHandler(ServerIntent.GET_TRANSMITTAL);
expect(handler).toBeDefined();
});
it('ควรคืน undefined สำหรับ intent ที่ไม่มีใน registry', () => {
const handler = service.getHandler('UNKNOWN_INTENT' as ServerIntent);
expect(handler).toBeUndefined();
});
});
describe('dispatch()', () => {
it('ควร dispatch GET_RFA และคืนผลลัพธ์ถูกต้อง', async () => {
const result = await service.dispatch(ServerIntent.GET_RFA, mockContext);
expect(result.ok).toBe(true);
if (result.ok) {
expect(Array.isArray(result.data)).toBe(true);
}
expect(mockRfaToolService.getRfa).toHaveBeenCalledWith(mockContext);
});
it('ควรคืน INVALID_PARAMS เมื่อ intent ไม่มีใน registry', async () => {
const result = await service.dispatch('UNKNOWN_INTENT', mockContext);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe('INVALID_PARAMS');
}
});
it('ควรบันทึก AuditLog ทุก dispatch', async () => {
await service.dispatch(ServerIntent.GET_RFA, mockContext);
expect(mockAuditLogRepo.create).toHaveBeenCalled();
expect(mockAuditLogRepo.save).toHaveBeenCalled();
});
it('ควรคืน SERVICE_ERROR เมื่อ handler โยน exception', async () => {
mockRfaToolService.getRfa.mockRejectedValueOnce(
new Error('DB connection failed')
);
const result = await service.dispatch(ServerIntent.GET_RFA, mockContext);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe('SERVICE_ERROR');
}
});
it('ควรบันทึก AuditLog status=FAILED เมื่อ handler คืน ok: false', async () => {
mockRfaToolService.getRfa.mockResolvedValueOnce({
ok: false,
reason: 'FORBIDDEN',
message: 'No permission',
});
await service.dispatch(ServerIntent.GET_RFA, mockContext);
expect(mockAuditLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
status: AiAuditStatus.FAILED,
})
);
});
});
});
@@ -0,0 +1,131 @@
// File: src/modules/ai/tool/ai-tool-registry.service.ts
// Change Log
// - 2026-05-19: สร้าง AiToolRegistryService — Static Map จาก ServerIntent ไปยัง Tool Handlers (ADR-025).
// - 2026-05-19: เพิ่ม Audit Logging สำหรับทุก Tool Execution (ADR-023, FR-005).
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { v7 as uuidv7 } from 'uuid';
import { ServerIntent } from './types/server-intent.enum';
import { ToolCallResult } from './types/tool-call-result.type';
import { ToolHandlerContext } from './types/tool-handler-context.type';
import { RfaToolService } from './rfa-tool.service';
import { DrawingToolService } from './drawing-tool.service';
import { TransmittalToolService } from './transmittal-tool.service';
import { AiAuditLog, AiAuditStatus } from '../entities/ai-audit-log.entity';
/** ชนิดของ Tool Handler function */
type ToolHandler = (
context: ToolHandlerContext
) => Promise<ToolCallResult<unknown>>;
@Injectable()
export class AiToolRegistryService {
private readonly logger = new Logger(AiToolRegistryService.name);
/** Static Map จาก ServerIntent ไปยัง Tool Handler */
private readonly handlerMap: Map<ServerIntent, ToolHandler>;
constructor(
private readonly rfaToolService: RfaToolService,
private readonly drawingToolService: DrawingToolService,
private readonly transmittalToolService: TransmittalToolService,
@InjectRepository(AiAuditLog)
private readonly auditLogRepo: Repository<AiAuditLog>
) {
// ลงทะเบียน handlers ใน Static Map ตาม ADR-025
this.handlerMap = new Map<ServerIntent, ToolHandler>([
[ServerIntent.GET_RFA, (ctx) => this.rfaToolService.getRfa(ctx)],
[
ServerIntent.GET_DRAWING,
(ctx) => this.drawingToolService.getDrawing(ctx),
],
[
ServerIntent.GET_TRANSMITTAL,
(ctx) => this.transmittalToolService.getTransmittal(ctx),
],
]);
}
/**
* ส่ง Intent ไปยัง Tool Handler ที่ตรงกัน
* พร้อม Audit Logging ทุก Execution (FR-005)
*/
async dispatch(
intent: string,
context: ToolHandlerContext
): Promise<ToolCallResult<unknown>> {
const startMs = Date.now();
const handler = this.handlerMap.get(intent as ServerIntent);
if (!handler) {
this.logger.warn(`ไม่พบ Handler สำหรับ Intent: ${intent}`);
const result: ToolCallResult<unknown> = {
ok: false,
reason: 'INVALID_PARAMS',
message: `ไม่รองรับ Intent '${intent}'`,
};
await this.writeAuditLog(intent, context, result, Date.now() - startMs);
return result;
}
let result: ToolCallResult<unknown>;
try {
result = await handler(context);
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(
`Tool Handler สำหรับ Intent '${intent}' เกิด exception: ${errMsg}`
);
result = {
ok: false,
reason: 'SERVICE_ERROR',
message: 'เกิดข้อผิดพลาดภายในระบบ กรุณาลองใหม่อีกครั้ง',
};
}
const latencyMs = Date.now() - startMs;
await this.writeAuditLog(intent, context, result, latencyMs);
return result;
}
/**
* คืน handler function สำหรับ Unit Test (ตรวจสอบว่ามี intent นั้นอยู่หรือไม่)
*/
getHandler(intent: ServerIntent): ToolHandler | undefined {
return this.handlerMap.get(intent);
}
/**
* บันทึก Audit Log ทุก Tool Execution (ADR-023 FR-005)
* ทำแบบ fire-and-forget เพื่อไม่บล็อก response
*/
private async writeAuditLog(
intent: string,
context: ToolHandlerContext,
result: ToolCallResult<unknown>,
latencyMs: number
): Promise<void> {
try {
const log = this.auditLogRepo.create({
publicId: uuidv7(),
aiModel: 'tool-layer', // ระบุ layer ใน model field
modelName: intent,
processingTimeMs: latencyMs,
status: result.ok ? AiAuditStatus.SUCCESS : AiAuditStatus.FAILED,
errorMessage: result.ok ? undefined : result.reason,
aiSuggestionJson: {
intent,
projectPublicId: context.projectPublicId,
userPublicId: context.requestUser.publicId,
params: context.params ?? {},
ok: result.ok,
reason: result.ok ? undefined : result.reason,
},
});
await this.auditLogRepo.save(log);
} catch (auditError: unknown) {
// Audit log ล้มเหลวต้องไม่กระทบ response หลัก (ข้อผิดพลาดเป็น non-critical)
this.logger.error(
`เขียน Audit Log ล้มเหลว: ${(auditError as Error).message}`
);
}
}
}
@@ -0,0 +1,255 @@
// File: src/modules/ai/tool/ai-tool-services.spec.ts
// Change Log
// - 2026-05-19: สร้าง Unit Test สำหรับ RfaToolService, DrawingToolService และ TransmittalToolService (ADR-025, ADR-016, ADR-019)
import { Test, TestingModule } from '@nestjs/testing';
import { RfaToolService } from './rfa-tool.service';
import { DrawingToolService } from './drawing-tool.service';
import { TransmittalToolService } from './transmittal-tool.service';
import { RfaService } from '../../rfa/rfa.service';
import { ShopDrawingService } from '../../drawing/shop-drawing.service';
import { TransmittalService } from '../../transmittal/transmittal.service';
import { AbilityFactory } from '../../../common/auth/casl/ability.factory';
import { UuidResolverService } from '../../../common/services/uuid-resolver.service';
import { ToolHandlerContext } from './types/tool-handler-context.type';
import { User } from '../../user/entities/user.entity';
describe('AI Tool Services (RFA, Drawing, Transmittal)', () => {
let rfaToolService: RfaToolService;
let drawingToolService: DrawingToolService;
let transmittalToolService: TransmittalToolService;
const mockUser = {
user_id: 1,
publicId: 'test-user-uuid',
} as unknown as User;
const mockContext: ToolHandlerContext = {
requestUser: mockUser,
projectPublicId: '0195a1b2-c3d4-7000-8000-abc123def456',
};
const mockAbility = {
can: jest.fn().mockReturnValue(true),
};
const mockAbilityFactory = {
createForUser: jest.fn().mockReturnValue(mockAbility),
};
const mockUuidResolver = {
resolveProjectId: jest.fn().mockResolvedValue(42),
};
const mockRfaService = {
findAll: jest.fn().mockResolvedValue({
data: [
{
publicId: 'rfa-uuid-1',
correspondence: {
correspondenceNumber: 'RFA-001',
},
revisions: [
{
revisionLabel: 'A',
issuedDate: new Date('2026-01-01T00:00:00Z'),
rfaRevision: {
statusCode: {
statusCode: 'APPROVED',
},
items: [{}, {}],
respondedAt: new Date('2026-01-02T00:00:00Z'),
},
},
],
},
],
}),
};
const mockShopDrawingService = {
findAll: jest.fn().mockResolvedValue({
data: [
{
publicId: 'drawing-uuid-1',
drawingNumber: 'DRW-001',
title: 'Shop Drawing 1',
status: 'APPROVED',
currentRevision: {
revisionLabel: 'B',
},
},
],
}),
};
const mockTransmittalService = {
findAll: jest.fn().mockResolvedValue({
data: [
{
correspondence: {
publicId: 'transmittal-uuid-1',
correspondenceNumber: 'TRN-001',
revisions: [
{
status: {
statusCode: 'ISSUED',
},
subject: 'Transmittal Subject 1',
issuedDate: new Date('2026-02-01T00:00:00Z'),
},
],
},
},
],
}),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RfaToolService,
DrawingToolService,
TransmittalToolService,
{ provide: AbilityFactory, useValue: mockAbilityFactory },
{ provide: UuidResolverService, useValue: mockUuidResolver },
{ provide: RfaService, useValue: mockRfaService },
{ provide: ShopDrawingService, useValue: mockShopDrawingService },
{ provide: TransmittalService, useValue: mockTransmittalService },
],
}).compile();
rfaToolService = module.get<RfaToolService>(RfaToolService);
drawingToolService = module.get<DrawingToolService>(DrawingToolService);
transmittalToolService = module.get<TransmittalToolService>(
TransmittalToolService
);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('RfaToolService', () => {
it('ควรดึงและแปลงข้อมูล RFA สำเร็จ (Happy Path)', async () => {
mockAbility.can.mockReturnValue(true);
const result = await rfaToolService.getRfa(mockContext);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
publicId: 'rfa-uuid-1',
rfaNumber: 'RFA-001',
revisionCode: 'A',
statusCode: 'APPROVED',
drawingCount: 2,
submittedAt: '2026-01-01T00:00:00.000Z',
respondedAt: '2026-01-02T00:00:00.000Z',
contractPublicId: '',
});
}
});
it('ควรปฏิเสธการเข้าถึงเมื่อไม่มีสิทธิ์ (CASL FORBIDDEN)', async () => {
mockAbility.can.mockReturnValue(false);
const result = await rfaToolService.getRfa(mockContext);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe('FORBIDDEN');
}
});
it('ควรจัดการข้อผิดพลาดระบบได้อย่างสง่างาม (SERVICE_ERROR)', async () => {
mockAbility.can.mockReturnValue(true);
mockRfaService.findAll.mockRejectedValueOnce(
new Error('Database Timeout')
);
const result = await rfaToolService.getRfa(mockContext);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe('SERVICE_ERROR');
}
});
});
describe('DrawingToolService', () => {
it('ควรดึงและแปลงข้อมูล Shop Drawing สำเร็จ (Happy Path)', async () => {
mockAbility.can.mockReturnValue(true);
const result = await drawingToolService.getDrawing(mockContext);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
publicId: 'drawing-uuid-1',
drawingNumber: 'DRW-001',
title: 'Shop Drawing 1',
statusCode: 'APPROVED',
drawingType: 'SHOP',
latestRevision: 'B',
contractPublicId: '',
});
}
});
it('ควรปฏิเสธการเข้าถึงเมื่อไม่มีสิทธิ์ (CASL FORBIDDEN)', async () => {
mockAbility.can.mockReturnValue(false);
const result = await drawingToolService.getDrawing(mockContext);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe('FORBIDDEN');
}
});
it('ควรจัดการข้อผิดพลาดระบบได้อย่างสง่างาม (SERVICE_ERROR)', async () => {
mockAbility.can.mockReturnValue(true);
mockShopDrawingService.findAll.mockRejectedValueOnce(
new Error('DB Error')
);
const result = await drawingToolService.getDrawing(mockContext);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe('SERVICE_ERROR');
}
});
});
describe('TransmittalToolService', () => {
it('ควรดึงและแปลงข้อมูล Transmittal สำเร็จ (Happy Path)', async () => {
mockAbility.can.mockReturnValue(true);
const result = await transmittalToolService.getTransmittal(mockContext);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
publicId: 'transmittal-uuid-1',
transmittalNumber: 'TRN-001',
statusCode: 'ISSUED',
subject: 'Transmittal Subject 1',
issuedAt: '2026-02-01T00:00:00.000Z',
projectPublicId: mockContext.projectPublicId,
});
}
});
it('ควรปฏิเสธการเข้าถึงเมื่อไม่มีสิทธิ์ (CASL FORBIDDEN)', async () => {
mockAbility.can.mockReturnValue(false);
const result = await transmittalToolService.getTransmittal(mockContext);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe('FORBIDDEN');
}
});
it('ควรจัดการข้อผิดพลาดระบบได้อย่างสง่างาม (SERVICE_ERROR)', async () => {
mockAbility.can.mockReturnValue(true);
mockTransmittalService.findAll.mockRejectedValueOnce(
new Error('Elastic Error')
);
const result = await transmittalToolService.getTransmittal(mockContext);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe('SERVICE_ERROR');
}
});
});
});
@@ -0,0 +1,43 @@
// File: src/modules/ai/tool/ai-tool.module.ts
// Change Log
// - 2026-05-19: สร้าง AiToolModule — submodule สำหรับ AI Tool Layer (ADR-025).
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AiToolRegistryService } from './ai-tool-registry.service';
import { RfaToolService } from './rfa-tool.service';
import { DrawingToolService } from './drawing-tool.service';
import { TransmittalToolService } from './transmittal-tool.service';
import { AiAuditLog } from '../entities/ai-audit-log.entity';
import { RfaModule } from '../../rfa/rfa.module';
import { DrawingModule } from '../../drawing/drawing.module';
import { TransmittalModule } from '../../transmittal/transmittal.module';
import { CaslModule } from '../../../common/auth/casl/casl.module';
import { CommonModule } from '../../../common/common.module';
/**
* AiToolModule — จัดการ Tool Registry และ Tool Service Handlers
* import โดย AiModule เพื่อใช้ AiToolRegistryService ใน AI Gateway (ADR-025)
*/
@Module({
imports: [
// Entity สำหรับ Audit Logging (FR-005)
TypeOrmModule.forFeature([AiAuditLog]),
// Domain Modules สำหรับ Tool Services
RfaModule,
DrawingModule,
TransmittalModule,
// CASL สำหรับ Authorization enforcement ใน Tool Handlers
CaslModule,
// CommonModule สำหรับ UuidResolverService
CommonModule,
],
providers: [
AiToolRegistryService,
RfaToolService,
DrawingToolService,
TransmittalToolService,
],
exports: [AiToolRegistryService],
})
export class AiToolModule {}
@@ -0,0 +1,93 @@
// File: src/modules/ai/tool/drawing-tool.service.ts
// Change Log
// - 2026-05-19: สร้าง DrawingToolService — Tool Handler สำหรับ Intent GET_DRAWING (ADR-025, ADR-016, ADR-019).
import { Injectable, Logger } from '@nestjs/common';
import { AbilityFactory } from '../../../common/auth/casl/ability.factory';
import { ShopDrawingService } from '../../drawing/shop-drawing.service';
import { UuidResolverService } from '../../../common/services/uuid-resolver.service';
import { ToolCallResult } from './types/tool-call-result.type';
import { ToolHandlerContext } from './types/tool-handler-context.type';
import { DrawingToolResult } from './types/drawing-tool-result.type';
interface ShopDrawingTransformed {
publicId: string;
drawingNumber?: string;
title?: string;
status?: string;
currentRevision?: {
revisionLabel?: string;
};
}
@Injectable()
export class DrawingToolService {
private readonly logger = new Logger(DrawingToolService.name);
constructor(
private readonly shopDrawingService: ShopDrawingService,
private readonly abilityFactory: AbilityFactory,
private readonly uuidResolver: UuidResolverService
) {}
/**
* ดึงข้อมูล Drawing (Shop Drawing) สำหรับ LLM context
* - ตรวจสอบสิทธิ์ด้วย CASL (ADR-016)
* - คืนเฉพาะ publicId + metadata ตาม ADR-019
* - จัดการ error แบบ Graceful Degradation (ADR-007)
*/
async getDrawing(
context: ToolHandlerContext
): Promise<ToolCallResult<DrawingToolResult[]>> {
// ตรวจสอบสิทธิ์ด้วย CASL ก่อนดึงข้อมูล
const ability = this.abilityFactory.createForUser(context.requestUser, {});
if (!ability.can('read', 'drawing')) {
this.logger.warn(
`ผู้ใช้ ${context.requestUser.publicId} ไม่มีสิทธิ์อ่าน Drawing`
);
return {
ok: false,
reason: 'FORBIDDEN',
message: 'คุณไม่มีสิทธิ์อ่านข้อมูล Drawing ในโครงการนี้',
};
}
try {
// แปลง projectPublicId → internal project id (ADR-019)
const internalProjectId = await this.uuidResolver.resolveProjectId(
context.projectPublicId
);
// ดึงข้อมูล Shop Drawing
const result = await this.shopDrawingService.findAll({
projectId: internalProjectId,
page: 1,
limit: 20,
});
// Map ผลลัพธ์ไปยัง DrawingToolResult — ห้าม expose integer id (ADR-019)
const data = result.data as unknown as ShopDrawingTransformed[];
const toolResults: DrawingToolResult[] = data
.filter((drawing) => drawing.publicId)
.map((drawing) => {
const latestRev = drawing.currentRevision;
return {
publicId: drawing.publicId,
drawingNumber: drawing.drawingNumber ?? '',
title: drawing.title ?? '',
statusCode: drawing.status ?? 'UNKNOWN',
drawingType: 'SHOP' as const,
latestRevision: latestRev?.revisionLabel ?? null,
contractPublicId: '', // เพิ่มภายหลังเมื่อ contract มี publicId
};
});
return { ok: true, data: toolResults };
} catch (error: unknown) {
this.logger.error(
`DrawingToolService.getDrawing เกิดข้อผิดพลาด: ${(error as Error).message}`
);
return {
ok: false,
reason: 'SERVICE_ERROR',
message: 'เกิดข้อผิดพลาดในการดึงข้อมูล Drawing กรุณาลองใหม่',
};
}
}
}
@@ -0,0 +1,94 @@
// File: src/modules/ai/tool/rfa-tool.service.ts
// Change Log
// - 2026-05-19: สร้าง RfaToolService — Tool Handler สำหรับ Intent GET_RFA (ADR-025, ADR-016, ADR-019).
import { Injectable, Logger } from '@nestjs/common';
import { AbilityFactory } from '../../../common/auth/casl/ability.factory';
import { RfaService } from '../../rfa/rfa.service';
import { UuidResolverService } from '../../../common/services/uuid-resolver.service';
import { ToolCallResult } from './types/tool-call-result.type';
import { ToolHandlerContext } from './types/tool-handler-context.type';
import { RfaToolResult } from './types/rfa-tool-result.type';
@Injectable()
export class RfaToolService {
private readonly logger = new Logger(RfaToolService.name);
constructor(
private readonly rfaService: RfaService,
private readonly abilityFactory: AbilityFactory,
private readonly uuidResolver: UuidResolverService
) {}
/**
* ดึงข้อมูล RFA สำหรับ LLM context
* - ตรวจสอบสิทธิ์ด้วย CASL (ADR-016)
* - คืนเฉพาะ publicId + business codes ตาม ADR-019
* - จัดการ error แบบ Graceful Degradation (ADR-007)
*/
async getRfa(
context: ToolHandlerContext
): Promise<ToolCallResult<RfaToolResult[]>> {
// ตรวจสอบสิทธิ์ด้วย CASL ก่อนดึงข้อมูล
const ability = this.abilityFactory.createForUser(context.requestUser, {});
if (!ability.can('read', 'rfa')) {
this.logger.warn(
`ผู้ใช้ ${context.requestUser.publicId} ไม่มีสิทธิ์อ่าน RFA`
);
return {
ok: false,
reason: 'FORBIDDEN',
message: 'คุณไม่มีสิทธิ์อ่านข้อมูล RFA ในโครงการนี้',
};
}
try {
// แปลง projectPublicId → internal project id (ADR-019)
const internalProjectId = await this.uuidResolver.resolveProjectId(
context.projectPublicId
);
// ดึงข้อมูล RFA จาก RfaService
const result = await this.rfaService.findAll(
{
projectId: internalProjectId,
revisionStatus: 'CURRENT',
limit: 20,
page: 1,
},
context.requestUser
);
// Map ผลลัพธ์ไปยัง RfaToolResult — ห้าม expose integer id (ADR-019)
const toolResults: RfaToolResult[] = result.data
.filter((rfa) => rfa.publicId)
.map((rfa) => {
const currentRevision = rfa.revisions?.[0];
const rfaRevision = currentRevision?.rfaRevision;
return {
publicId: rfa.publicId as string,
rfaNumber: rfa.correspondence?.correspondenceNumber ?? '',
revisionCode: currentRevision?.revisionLabel ?? '0',
statusCode: rfaRevision?.statusCode?.statusCode ?? 'UNKNOWN',
drawingCount: rfaRevision?.items?.length ?? 0,
submittedAt: currentRevision?.issuedDate
? currentRevision.issuedDate.toISOString()
: null,
respondedAt: rfaRevision?.respondedAt
? new Date(
rfaRevision.respondedAt as string | number | Date
).toISOString()
: null,
contractPublicId: '', // Contract publicId — ถ้า contract entity มี publicId ให้เพิ่มทีหลัง
};
});
return { ok: true, data: toolResults };
} catch (error: unknown) {
this.logger.error(
`RfaToolService.getRfa เกิดข้อผิดพลาด: ${(error as Error).message}`
);
return {
ok: false,
reason: 'SERVICE_ERROR',
message: 'เกิดข้อผิดพลาดในการดึงข้อมูล RFA กรุณาลองใหม่',
};
}
}
}
@@ -0,0 +1,83 @@
// File: src/modules/ai/tool/transmittal-tool.service.ts
// Change Log
// - 2026-05-19: สร้าง TransmittalToolService — Tool Handler สำหรับ Intent GET_TRANSMITTAL (ADR-025, ADR-016, ADR-019).
import { Injectable, Logger } from '@nestjs/common';
import { AbilityFactory } from '../../../common/auth/casl/ability.factory';
import { TransmittalService } from '../../transmittal/transmittal.service';
import { UuidResolverService } from '../../../common/services/uuid-resolver.service';
import { ToolCallResult } from './types/tool-call-result.type';
import { ToolHandlerContext } from './types/tool-handler-context.type';
import { TransmittalToolResult } from './types/transmittal-tool-result.type';
@Injectable()
export class TransmittalToolService {
private readonly logger = new Logger(TransmittalToolService.name);
constructor(
private readonly transmittalService: TransmittalService,
private readonly abilityFactory: AbilityFactory,
private readonly uuidResolver: UuidResolverService
) {}
/**
* ดึงข้อมูล Transmittal สำหรับ LLM context
* - ตรวจสอบสิทธิ์ด้วย CASL (ADR-016)
* - คืนเฉพาะ publicId + business codes ตาม ADR-019
* - จัดการ error แบบ Graceful Degradation (ADR-007)
*/
async getTransmittal(
context: ToolHandlerContext
): Promise<ToolCallResult<TransmittalToolResult[]>> {
// ตรวจสอบสิทธิ์ด้วย CASL ก่อนดึงข้อมูล
const ability = this.abilityFactory.createForUser(context.requestUser, {});
if (!ability.can('read', 'transmittal')) {
this.logger.warn(
`ผู้ใช้ ${context.requestUser.publicId} ไม่มีสิทธิ์อ่าน Transmittal`
);
return {
ok: false,
reason: 'FORBIDDEN',
message: 'คุณไม่มีสิทธิ์อ่านข้อมูล Transmittal ในโครงการนี้',
};
}
try {
// แปลง projectPublicId → internal project id (ADR-019)
const internalProjectId = await this.uuidResolver.resolveProjectId(
context.projectPublicId
);
// ดึงข้อมูล Transmittal
const result = await this.transmittalService.findAll({
projectId: internalProjectId,
page: 1,
limit: 20,
});
// Map ผลลัพธ์ไปยัง TransmittalToolResult — ห้าม expose integer id (ADR-019)
const toolResults: TransmittalToolResult[] = result.data
.filter((t) => t.correspondence?.publicId)
.map((t) => {
const currentRevision = t.correspondence?.revisions?.[0];
return {
publicId: t.correspondence.publicId,
transmittalNumber: t.correspondence?.correspondenceNumber ?? '',
statusCode: currentRevision?.status?.statusCode ?? 'UNKNOWN',
subject: currentRevision?.subject ?? '',
issuedAt: currentRevision?.issuedDate
? currentRevision.issuedDate.toISOString()
: null,
projectPublicId: context.projectPublicId,
};
});
return { ok: true, data: toolResults };
} catch (error: unknown) {
this.logger.error(
`TransmittalToolService.getTransmittal เกิดข้อผิดพลาด: ${(error as Error).message}`
);
return {
ok: false,
reason: 'SERVICE_ERROR',
message: 'เกิดข้อผิดพลาดในการดึงข้อมูล Transmittal กรุณาลองใหม่',
};
}
}
}
@@ -0,0 +1,24 @@
// File: src/modules/ai/tool/types/drawing-tool-result.type.ts
// Change Log
// - 2026-05-19: สร้าง DrawingToolResult DTO สำหรับ AI Tool Layer (ADR-019, ADR-025).
/**
* ผลลัพธ์ Drawing สำหรับ LLM Context
* ปฏิบัติตาม ADR-019: ไม่มี Integer Primary Key (`id`)
*/
export interface DrawingToolResult {
/** UUID ของ Drawing (ADR-019) */
publicId: string;
/** เลขที่ Drawing */
drawingNumber: string;
/** ชื่อ Drawing */
title: string;
/** รหัสสถานะ เช่น ACTIVE, SUPERSEDED */
statusCode: string;
/** ประเภท Drawing: SHOP หรือ AS_BUILT */
drawingType: 'SHOP' | 'AS_BUILT';
/** Revision ล่าสุด */
latestRevision: string | null;
/** UUID ของ Contract (ADR-019) */
contractPublicId: string;
}
@@ -0,0 +1,27 @@
// File: src/modules/ai/tool/types/rfa-tool-result.type.ts
// Change Log
// - 2026-05-19: สร้าง RfaToolResult DTO สำหรับ AI Tool Layer (ADR-019, ADR-025).
/**
* ผลลัพธ์ RFA สำหรับ LLM Context
* ปฏิบัติตาม ADR-019: ไม่มี Integer Primary Key (`id`),
* ใช้เฉพาะ publicId และ Business Codes
*/
export interface RfaToolResult {
/** UUID ของ RFA (ADR-019) */
publicId: string;
/** เลขที่เอกสาร RFA */
rfaNumber: string;
/** รหัส Revision */
revisionCode: string;
/** รหัสสถานะ เช่น DFT, FAP, APP */
statusCode: string;
/** จำนวน Drawing ที่อ้างอิง */
drawingCount: number;
/** วันที่ส่ง (ISO 8601 หรือ null) */
submittedAt: string | null;
/** วันที่ตอบกลับ (ISO 8601 หรือ null) */
respondedAt: string | null;
/** UUID ของ Contract ที่เกี่ยวข้อง (ADR-019) */
contractPublicId: string;
}
@@ -0,0 +1,16 @@
// File: src/modules/ai/tool/types/server-intent.enum.ts
// Change Log
// - 2026-05-19: สร้าง ServerIntent enum สำหรับ AI Tool Layer (ADR-024, ADR-025).
/**
* Server-side Intent codes ที่ AI Gateway รองรับ
* ทุก Intent จะถูก map ไปยัง Tool Handler ใน AiToolRegistryService
*/
export enum ServerIntent {
/** ดึงข้อมูล RFA สำหรับ LLM context */
GET_RFA = 'GET_RFA',
/** ดึงข้อมูล Drawing (Shop/As-Built) สำหรับ LLM context */
GET_DRAWING = 'GET_DRAWING',
/** ดึงข้อมูล Transmittal สำหรับ LLM context */
GET_TRANSMITTAL = 'GET_TRANSMITTAL',
}
@@ -0,0 +1,22 @@
// File: src/modules/ai/tool/types/tool-call-result.type.ts
// Change Log
// - 2026-05-19: สร้าง ToolCallReason และ ToolCallResult<T> สำหรับ AI Tool Layer (ADR-025, ADR-007, ADR-019).
/**
* ประเภทของ Reason เมื่อ Tool ทำงานไม่สำเร็จ
* ตาม ADR-007 Layered Error Classification
*/
export type ToolCallReason =
| 'FORBIDDEN' // ไม่มีสิทธิ์ (CASL fail)
| 'NOT_FOUND' // ไม่พบข้อมูล
| 'INVALID_PARAMS' // พารามิเตอร์ไม่ถูกต้อง
| 'SERVICE_ERROR'; // ข้อผิดพลาดจาก Service layer
/**
* ผลลัพธ์จากการเรียก Tool — Discriminated Union
* ok: true → data พร้อมใช้งาน
* ok: false → reason บอกสาเหตุ, message สำหรับ LLM context
*/
export type ToolCallResult<T> =
| { ok: true; data: T }
| { ok: false; reason: ToolCallReason; message: string };
@@ -0,0 +1,18 @@
// File: src/modules/ai/tool/types/tool-handler-context.type.ts
// Change Log
// - 2026-05-19: สร้าง ToolHandlerContext สำหรับส่ง context ไปยัง Tool Handlers (ADR-025).
import { User } from '../../../user/entities/user.entity';
/**
* Context ที่ส่งไปยัง Tool Handler ทุกตัว
* ใช้สำหรับ CASL authorization และ query filtering
*/
export interface ToolHandlerContext {
/** User ที่ร้องขอ — ใช้สำหรับ CASL check */
requestUser: User;
/** UUID ของ Project ที่ต้องการดึงข้อมูล (ADR-023A: mandatory for Qdrant isolation) */
projectPublicId: string;
/** Parameters เพิ่มเติม (เช่น statusCode, limit, search) */
params?: Record<string, unknown>;
}
@@ -0,0 +1,22 @@
// File: src/modules/ai/tool/types/transmittal-tool-result.type.ts
// Change Log
// - 2026-05-19: สร้าง TransmittalToolResult DTO สำหรับ AI Tool Layer (ADR-019, ADR-025).
/**
* ผลลัพธ์ Transmittal สำหรับ LLM Context
* ปฏิบัติตาม ADR-019: ไม่มี Integer Primary Key (`id`)
*/
export interface TransmittalToolResult {
/** UUID ของ Transmittal (ADR-019) */
publicId: string;
/** เลขที่เอกสาร Transmittal */
transmittalNumber: string;
/** รหัสสถานะ */
statusCode: string;
/** หัวข้อ */
subject: string;
/** วันที่ออก */
issuedAt: string | null;
/** UUID ของ Project (ADR-019) */
projectPublicId: string;
}