This commit is contained in:
@@ -1,133 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const filepath = 'd:/nap-dms.lcbp3/specs/03-Data-and-Storage/n8n.workflow.json';
|
||||
const workflow = JSON.parse(fs.readFileSync(filepath, 'utf8'));
|
||||
|
||||
const switchNodeIndex = workflow.nodes.findIndex(n => n.name === 'Route by Confidence');
|
||||
|
||||
if (switchNodeIndex > -1) {
|
||||
workflow.nodes[switchNodeIndex] = {
|
||||
id: "23d11b5e-49b4-4b53-911b-76b6bb77aab8",
|
||||
name: "Route by Confidence",
|
||||
type: "n8n-nodes-base.switch",
|
||||
typeVersion: 3.2,
|
||||
position: [6840, 3696],
|
||||
parameters: {
|
||||
rules: {
|
||||
values: [
|
||||
{
|
||||
conditions: {
|
||||
options: {
|
||||
caseSensitive: true,
|
||||
leftValue: "",
|
||||
typeValidation: "strict",
|
||||
version: 2
|
||||
},
|
||||
conditions: [
|
||||
{
|
||||
leftValue: "={{ $json.route_index }}",
|
||||
rightValue: 0,
|
||||
operator: {
|
||||
type: "number",
|
||||
operation: "equals",
|
||||
singleValue: true
|
||||
}
|
||||
}
|
||||
],
|
||||
combinator: "and"
|
||||
},
|
||||
renameOutput: true,
|
||||
outputKey: "Auto Ingest"
|
||||
},
|
||||
{
|
||||
conditions: {
|
||||
options: {
|
||||
caseSensitive: true,
|
||||
leftValue: "",
|
||||
typeValidation: "strict",
|
||||
version: 2
|
||||
},
|
||||
conditions: [
|
||||
{
|
||||
leftValue: "={{ $json.route_index }}",
|
||||
rightValue: 1,
|
||||
operator: {
|
||||
type: "number",
|
||||
operation: "equals",
|
||||
singleValue: true
|
||||
}
|
||||
}
|
||||
],
|
||||
combinator: "and"
|
||||
},
|
||||
renameOutput: true,
|
||||
outputKey: "Review Queue"
|
||||
},
|
||||
{
|
||||
conditions: {
|
||||
options: {
|
||||
caseSensitive: true,
|
||||
leftValue: "",
|
||||
typeValidation: "strict",
|
||||
version: 2
|
||||
},
|
||||
conditions: [
|
||||
{
|
||||
leftValue: "={{ $json.route_index }}",
|
||||
rightValue: 2,
|
||||
operator: {
|
||||
type: "number",
|
||||
operation: "equals",
|
||||
singleValue: true
|
||||
}
|
||||
}
|
||||
],
|
||||
combinator: "and"
|
||||
},
|
||||
renameOutput: true,
|
||||
outputKey: "Reject"
|
||||
},
|
||||
{
|
||||
conditions: {
|
||||
options: {
|
||||
caseSensitive: true,
|
||||
leftValue: "",
|
||||
typeValidation: "strict",
|
||||
version: 2
|
||||
},
|
||||
conditions: [
|
||||
{
|
||||
leftValue: "={{ $json.route_index }}",
|
||||
rightValue: 3,
|
||||
operator: {
|
||||
type: "number",
|
||||
operation: "equals",
|
||||
singleValue: true
|
||||
}
|
||||
}
|
||||
],
|
||||
combinator: "and"
|
||||
},
|
||||
renameOutput: true,
|
||||
outputKey: "Error Log"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (workflow.connections['Confidence Router'] || workflow.connections['Route by Confidence']) {
|
||||
workflow.connections['Route by Confidence'] = {
|
||||
'main': [
|
||||
[ { 'node': 'Import to Backend', 'type': 'main', 'index': 0 } ],
|
||||
[ { 'node': 'Insert Review Queue', 'type': 'main', 'index': 0 } ],
|
||||
[ { 'node': 'Log Reject to CSV', 'type': 'main', 'index': 0 } ],
|
||||
[ { 'node': 'Log Error to CSV', 'type': 'main', 'index': 0 } ]
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
fs.writeFileSync(filepath, JSON.stringify(workflow, null, 2));
|
||||
console.log("Updated Switch node to use typeVersion 3.2 properly.");
|
||||
} else {
|
||||
console.log("Could not find Route by Confidence node.");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.5.1",
|
||||
"version": "1.8.0",
|
||||
"description": "<p align=\"center\">\r <a href=\"http://nestjs.com/\" target=\"blank\"><img src=\"https://nestjs.com/img/logo-small.svg\" width=\"120\" alt=\"Nest Logo\" /></a>\r </p>",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('CorrespondenceController', () => {
|
||||
|
||||
const mockReq = { user: { user_id: 1 } };
|
||||
const result = await controller.submit(
|
||||
1,
|
||||
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
|
||||
{ note: 'Test note' },
|
||||
mockReq as Parameters<typeof controller.submit>[2]
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
UseGuards,
|
||||
Request,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
Delete,
|
||||
Put,
|
||||
@@ -43,7 +42,7 @@ export class CorrespondenceController {
|
||||
private readonly workflowService: CorrespondenceWorkflowService
|
||||
) {}
|
||||
|
||||
@Post(':id/workflow/action')
|
||||
@Post(':uuid/workflow/action')
|
||||
@ApiOperation({ summary: 'Process workflow action (Approve/Reject/Review)' })
|
||||
@ApiResponse({ status: 201, description: 'Action processed successfully.' })
|
||||
@RequirePermission('workflow.action_review')
|
||||
@@ -188,15 +187,16 @@ export class CorrespondenceController {
|
||||
return this.correspondenceService.addReference(corr.id, dto);
|
||||
}
|
||||
|
||||
@Delete(':uuid/references/:targetId')
|
||||
@Delete(':uuid/references/:targetUuid')
|
||||
@ApiOperation({ summary: 'Remove reference' })
|
||||
@ApiResponse({ status: 200, description: 'Reference removed successfully.' })
|
||||
@RequirePermission('document.edit')
|
||||
async removeReference(
|
||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||
@Param('targetId', ParseIntPipe) targetId: number
|
||||
@Param('targetUuid', ParseUuidPipe) targetUuid: string
|
||||
) {
|
||||
const corr = await this.correspondenceService.findOneByUuid(uuid);
|
||||
return this.correspondenceService.removeReference(corr.id, targetId);
|
||||
const target = await this.correspondenceService.findOneByUuid(targetUuid);
|
||||
return this.correspondenceService.removeReference(corr.id, target.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,8 +423,9 @@ export class CorrespondenceService {
|
||||
|
||||
async addReference(id: number, dto: AddReferenceDto) {
|
||||
const source = await this.correspondenceRepo.findOne({ where: { id } });
|
||||
// ADR-019: Resolve target UUID → internal INT id
|
||||
const target = await this.correspondenceRepo.findOne({
|
||||
where: { id: dto.targetId },
|
||||
where: { uuid: dto.targetUuid },
|
||||
});
|
||||
|
||||
if (!source || !target) {
|
||||
@@ -438,7 +439,7 @@ export class CorrespondenceService {
|
||||
const exists = await this.referenceRepo.findOne({
|
||||
where: {
|
||||
sourceId: id,
|
||||
targetId: dto.targetId,
|
||||
targetId: target.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -448,7 +449,7 @@ export class CorrespondenceService {
|
||||
|
||||
const ref = this.referenceRepo.create({
|
||||
sourceId: id,
|
||||
targetId: dto.targetId,
|
||||
targetId: target.id,
|
||||
});
|
||||
|
||||
return this.referenceRepo.save(ref);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { IsInt, IsNotEmpty } from 'class-validator';
|
||||
import { IsUUID, IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class AddReferenceDto {
|
||||
@ApiProperty({
|
||||
description: 'Target Correspondence ID to reference',
|
||||
example: 20,
|
||||
description: 'Target Correspondence UUID to reference (ADR-019)',
|
||||
example: '019505a1-7c3e-7000-8000-abc123def456',
|
||||
})
|
||||
@IsInt()
|
||||
@IsUUID('all')
|
||||
@IsNotEmpty()
|
||||
targetId!: number;
|
||||
targetUuid!: string;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export class CreateCorrespondenceDto {
|
||||
})
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
details?: Record<string, any>; // ข้อมูล JSON (เช่น RFI question)
|
||||
details?: Record<string, unknown>; // ข้อมูล JSON (เช่น RFI question)
|
||||
|
||||
@ApiPropertyOptional({ description: 'Is internal document?', default: false })
|
||||
@IsBoolean()
|
||||
|
||||
@@ -53,7 +53,7 @@ export class CorrespondenceRevision extends UuidBaseEntity {
|
||||
remarks?: string;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
details?: any; // เก็บข้อมูลแบบ Dynamic ตาม Type
|
||||
details?: object; // Dynamic JSON — typed as `object` per TypeORM JSON column convention (no-any, ADR-019)
|
||||
|
||||
@Column({ name: 'schema_version', default: 1 })
|
||||
schemaVersion!: number;
|
||||
|
||||
@@ -267,12 +267,17 @@ export class DocumentNumberingService {
|
||||
// --- Admin / Legacy ---
|
||||
|
||||
async getTemplates() {
|
||||
return this.formatRepo.find();
|
||||
return this.formatRepo.find({
|
||||
relations: ['project', 'correspondenceType'],
|
||||
});
|
||||
}
|
||||
|
||||
async getTemplatesByProject(projectId: number | string) {
|
||||
const internalId = await this.resolveProjectId(projectId);
|
||||
return this.formatRepo.find({ where: { projectId: internalId } });
|
||||
return this.formatRepo.find({
|
||||
where: { projectId: internalId },
|
||||
relations: ['project', 'correspondenceType'],
|
||||
});
|
||||
}
|
||||
|
||||
async saveTemplate(dto: any) {
|
||||
|
||||
@@ -33,11 +33,12 @@ describe('MigrationController', () => {
|
||||
it('should call importCorrespondence on service', async () => {
|
||||
const dto: ImportCorrespondenceDto = {
|
||||
document_number: 'DOC-001',
|
||||
title: 'Legacy Record',
|
||||
subject: 'Legacy Record',
|
||||
category: 'Correspondence',
|
||||
source_file_path: '/staging_ai/test.pdf',
|
||||
migrated_by: 'SYSTEM_IMPORT',
|
||||
batch_id: 'batch1',
|
||||
project_id: 1,
|
||||
};
|
||||
|
||||
const idempotencyKey = 'key123';
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { IsInt, IsOptional, IsString, IsNotEmpty } from 'class-validator';
|
||||
import { IsInt, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class SearchRfaDto {
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
@IsNotEmpty()
|
||||
projectId!: number; // บังคับระบุ Project
|
||||
@IsUUID('all')
|
||||
projectUuid!: string; // ADR-019: Public UUID of the project
|
||||
|
||||
/** @internal Resolved INT ID — set by controller, do NOT expose in API */
|
||||
projectId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@@ -13,9 +14,12 @@ export class SearchRfaDto {
|
||||
rfaTypeId?: number; // กรองตามประเภท RFA
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
statusId?: number; // กรองตามสถานะ (เช่น Draft, For Approve)
|
||||
@IsString()
|
||||
statusCode?: string; // กรองตามสถานะโดยใช้ status code เช่น 'DFT', 'FAP'
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
revisionStatus?: string; // 'CURRENT' | 'OLD' | 'ALL' — default 'CURRENT'
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -35,7 +35,7 @@ export class RfaRevision {
|
||||
// --- JSON & Schema Section ---
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
details?: any;
|
||||
details?: object; // Dynamic JSON — typed as `object` per TypeORM JSON column convention (no-any, ADR-019)
|
||||
|
||||
// ✅ [New] จำเป็นสำหรับ Data Migration (T2.5.5)
|
||||
@Column({ name: 'schema_version', default: 1 })
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
@@ -22,6 +21,7 @@ import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { CreateRfaDto } from './dto/create-rfa.dto';
|
||||
import { SubmitRfaDto } from './dto/submit-rfa.dto';
|
||||
import { SearchRfaDto } from './dto/search-rfa.dto';
|
||||
import { RfaService } from './rfa.service';
|
||||
|
||||
import { Audit } from '../../common/decorators/audit.decorator';
|
||||
@@ -29,13 +29,18 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RbacGuard } from '../../common/guards/rbac.guard';
|
||||
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
|
||||
import { ProjectService } from '../project/project.service';
|
||||
|
||||
@ApiTags('RFA (Request for Approval)')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@Controller('rfas')
|
||||
export class RfaController {
|
||||
constructor(private readonly rfaService: RfaService) {}
|
||||
constructor(
|
||||
private readonly rfaService: RfaService,
|
||||
private readonly projectService: ProjectService
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create new RFA (Draft)' })
|
||||
@@ -47,24 +52,26 @@ export class RfaController {
|
||||
return this.rfaService.create(createDto, user);
|
||||
}
|
||||
|
||||
@Post(':id/submit')
|
||||
@Post(':uuid/submit')
|
||||
@ApiOperation({ summary: 'Submit RFA to Workflow' })
|
||||
@ApiParam({ name: 'id', description: 'RFA ID' })
|
||||
@ApiParam({ name: 'uuid', description: 'RFA UUID (from correspondences.uuid)' })
|
||||
@ApiBody({ type: SubmitRfaDto })
|
||||
@ApiResponse({ status: 200, description: 'RFA submitted successfully' })
|
||||
@RequirePermission('rfa.create')
|
||||
@Audit('rfa.submit', 'rfa')
|
||||
submit(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
async submit(
|
||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||
@Body() submitDto: SubmitRfaDto,
|
||||
@CurrentUser() user: User
|
||||
) {
|
||||
return this.rfaService.submit(id, submitDto.templateId, user);
|
||||
// ADR-019: resolve UUID → internal INT id via findOneByUuidRaw
|
||||
const rfa = await this.rfaService.findOneByUuidRaw(uuid);
|
||||
return this.rfaService.submit(rfa.id, submitDto.templateId, user);
|
||||
}
|
||||
|
||||
@Post(':id/action')
|
||||
@Post(':uuid/action')
|
||||
@ApiOperation({ summary: 'Process Workflow Action (Approve/Reject)' })
|
||||
@ApiParam({ name: 'id', description: 'RFA ID' })
|
||||
@ApiParam({ name: 'uuid', description: 'RFA UUID (from correspondences.uuid)' })
|
||||
@ApiBody({ type: WorkflowActionDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -72,28 +79,33 @@ export class RfaController {
|
||||
})
|
||||
@RequirePermission('workflow.action_review')
|
||||
@Audit('rfa.action', 'rfa')
|
||||
processAction(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
async processAction(
|
||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||
@Body() actionDto: WorkflowActionDto,
|
||||
@CurrentUser() user: User
|
||||
) {
|
||||
return this.rfaService.processAction(id, actionDto, user);
|
||||
// ADR-019: resolve UUID → internal INT id
|
||||
const rfa = await this.rfaService.findOneByUuidRaw(uuid);
|
||||
return this.rfaService.processAction(rfa.id, actionDto, user);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all RFAs with pagination' })
|
||||
@ApiResponse({ status: 200, description: 'List of RFAs' })
|
||||
@RequirePermission('document.view')
|
||||
findAll(@Query() query: any) {
|
||||
async findAll(@Query() query: SearchRfaDto) {
|
||||
// ADR-019: resolve projectUuid → internal INT projectId
|
||||
const project = await this.projectService.findOneByUuid(query.projectUuid);
|
||||
query.projectId = project.id;
|
||||
return this.rfaService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Get(':uuid')
|
||||
@ApiOperation({ summary: 'Get RFA details with revisions and items' })
|
||||
@ApiParam({ name: 'id', description: 'RFA ID' })
|
||||
@ApiParam({ name: 'uuid', description: 'RFA UUID (from correspondences.uuid)' })
|
||||
@ApiResponse({ status: 200, description: 'RFA details' })
|
||||
@RequirePermission('document.view')
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.rfaService.findOne(id);
|
||||
findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
|
||||
return this.rfaService.findOneByUuid(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { RfaService } from './rfa.service';
|
||||
// External Modules
|
||||
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
import { ProjectModule } from '../project/project.module';
|
||||
import { SearchModule } from '../search/search.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module';
|
||||
@@ -56,6 +57,7 @@ import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module'
|
||||
]),
|
||||
DocumentNumberingModule,
|
||||
UserModule,
|
||||
ProjectModule,
|
||||
SearchModule,
|
||||
WorkflowEngineModule,
|
||||
NotificationModule,
|
||||
|
||||
@@ -32,6 +32,17 @@ import { Rfa } from './entities/rfa.entity';
|
||||
// DTOs
|
||||
import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto';
|
||||
import { CreateRfaDto } from './dto/create-rfa.dto';
|
||||
import { SearchRfaDto } from './dto/search-rfa.dto';
|
||||
|
||||
// ------- Local type helpers (no-any ADR-019) -------
|
||||
/** CorrespondenceRevision with the rfaRevision relation loaded at runtime */
|
||||
type CorrRevWithRfa = CorrespondenceRevision & { rfaRevision?: RfaRevision };
|
||||
|
||||
/** RFA entity + a flat `revisions` convenience array for the frontend */
|
||||
export interface RfaMapped extends Rfa {
|
||||
uuid?: string; // ADR-019: top-level UUID from correspondence
|
||||
revisions: CorrRevWithRfa[];
|
||||
}
|
||||
|
||||
// Interfaces & Enums
|
||||
import { WorkflowAction } from '../workflow-engine/interfaces/workflow.interface';
|
||||
@@ -272,6 +283,7 @@ export class RfaService {
|
||||
this.searchService
|
||||
.indexDocument({
|
||||
id: savedCorr.id,
|
||||
uuid: savedCorr.uuid, // ADR-019: index UUID for search
|
||||
type: 'rfa',
|
||||
docNumber: docNumber.number,
|
||||
title: createDto.subject,
|
||||
@@ -298,13 +310,19 @@ export class RfaService {
|
||||
|
||||
// ... (ส่วน findOne, submit, processAction คงเดิมจากไฟล์ที่แนบมา แค่ปรับปรุงเล็กน้อยตาม Context) ...
|
||||
|
||||
async findAll(query: any) {
|
||||
const { page = 1, limit = 20, projectId, status, search } = query;
|
||||
async findAll(query: SearchRfaDto) {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
projectId,
|
||||
search,
|
||||
revisionStatus = 'CURRENT',
|
||||
statusCode,
|
||||
} = query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Fix: Start query from Rfa entity instead of Correspondence,
|
||||
// because Correspondence has no 'rfas' relation.
|
||||
// [Force Rebuild]
|
||||
const queryBuilder = this.rfaRepo
|
||||
.createQueryBuilder('rfa')
|
||||
.leftJoinAndSelect('rfa.correspondence', 'corr')
|
||||
@@ -318,11 +336,9 @@ export class RfaService {
|
||||
.leftJoinAndSelect('sdRev.attachments', 'attachments');
|
||||
|
||||
// Filter by Revision Status (from query param 'revisionStatus')
|
||||
const revStatus = query.revisionStatus || 'CURRENT';
|
||||
|
||||
if (revStatus === 'CURRENT') {
|
||||
if (revisionStatus === 'CURRENT') {
|
||||
queryBuilder.where('corrRev.isCurrent = :isCurrent', { isCurrent: true });
|
||||
} else if (revStatus === 'OLD') {
|
||||
} else if (revisionStatus === 'OLD') {
|
||||
queryBuilder.where('corrRev.isCurrent = :isCurrent', {
|
||||
isCurrent: false,
|
||||
});
|
||||
@@ -333,8 +349,8 @@ export class RfaService {
|
||||
queryBuilder.andWhere('corr.projectId = :projectId', { projectId });
|
||||
}
|
||||
|
||||
if (status) {
|
||||
queryBuilder.andWhere('status.statusCode = :status', { status });
|
||||
if (statusCode) {
|
||||
queryBuilder.andWhere('status.statusCode = :statusCode', { statusCode });
|
||||
}
|
||||
|
||||
if (search) {
|
||||
@@ -355,15 +371,18 @@ export class RfaService {
|
||||
);
|
||||
|
||||
// Map `revisions` property back to the expected payload for the frontend
|
||||
const mappedItems = items.map((rfa) => {
|
||||
const mappedRfa = { ...rfa } as any;
|
||||
mappedRfa.revisions =
|
||||
rfa.correspondence?.revisions?.map((cr) => ({
|
||||
const mappedItems: RfaMapped[] = items.map((rfa) => {
|
||||
const revisions =
|
||||
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
|
||||
return {
|
||||
...rfa,
|
||||
uuid: rfa.correspondence?.uuid, // ADR-019: expose UUID at top level
|
||||
revisions: revisions.map((cr) => ({
|
||||
...cr,
|
||||
...(cr.rfaRevision || {}),
|
||||
id: cr.rfaRevision?.id || cr.id,
|
||||
})) || [];
|
||||
return mappedRfa;
|
||||
...(cr.rfaRevision ?? {}),
|
||||
id: cr.rfaRevision?.id ?? cr.id,
|
||||
})) as CorrRevWithRfa[],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -377,6 +396,32 @@ export class RfaService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ADR-019: Find RFA by the parent Correspondence UUID (public identifier).
|
||||
* Resolves correspondence.uuid → internal rfa.id
|
||||
*/
|
||||
async findOneByUuid(uuid: string) {
|
||||
const correspondence = await this.correspondenceRepo.findOne({
|
||||
where: { uuid },
|
||||
select: ['id'],
|
||||
});
|
||||
if (!correspondence) {
|
||||
throw new NotFoundException(`RFA with UUID ${uuid} not found`);
|
||||
}
|
||||
return this.findOne(correspondence.id);
|
||||
}
|
||||
|
||||
async findOneByUuidRaw(uuid: string) {
|
||||
const correspondence = await this.correspondenceRepo.findOne({
|
||||
where: { uuid },
|
||||
select: ['id'],
|
||||
});
|
||||
if (!correspondence) {
|
||||
throw new NotFoundException(`RFA with UUID ${uuid} not found`);
|
||||
}
|
||||
return this.findOne(correspondence.id, true);
|
||||
}
|
||||
|
||||
async findOne(id: number, rawEntities = false) {
|
||||
const rfa = await this.rfaRepo.findOne({
|
||||
where: { id },
|
||||
@@ -405,22 +450,26 @@ export class RfaService {
|
||||
}
|
||||
|
||||
// Map to structure expected by frontend DTO
|
||||
const mappedRfa = { ...rfa } as any;
|
||||
mappedRfa.revisions =
|
||||
rfa.correspondence?.revisions?.map((cr) => ({
|
||||
const revisions =
|
||||
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
|
||||
const mappedRfa: RfaMapped = {
|
||||
...rfa,
|
||||
uuid: rfa.correspondence?.uuid, // ADR-019: expose UUID at top level
|
||||
revisions: revisions.map((cr) => ({
|
||||
...cr,
|
||||
...(cr.rfaRevision || {}),
|
||||
id: cr.rfaRevision?.id || cr.id,
|
||||
})) || [];
|
||||
...(cr.rfaRevision ?? {}),
|
||||
id: cr.rfaRevision?.id ?? cr.id,
|
||||
})) as CorrRevWithRfa[],
|
||||
};
|
||||
|
||||
return mappedRfa;
|
||||
}
|
||||
|
||||
async submit(rfaId: number, templateId: number, user: User) {
|
||||
const rfa = await this.findOne(rfaId, true);
|
||||
const currentCorrRev = rfa.correspondence?.revisions?.find(
|
||||
(r: any) => r.isCurrent
|
||||
);
|
||||
const corrRevisions =
|
||||
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
|
||||
const currentCorrRev = corrRevisions.find((r) => r.isCurrent);
|
||||
if (!currentCorrRev || !currentCorrRev.rfaRevision)
|
||||
throw new NotFoundException('Current revision not found');
|
||||
|
||||
@@ -512,9 +561,9 @@ export class RfaService {
|
||||
async processAction(rfaId: number, dto: WorkflowActionDto, user: User) {
|
||||
// Logic คงเดิม: หา Current Routing -> Check Permission -> Call Workflow Engine -> Update DB
|
||||
const rfa = await this.findOne(rfaId, true);
|
||||
const currentCorrRev = rfa.correspondence?.revisions?.find(
|
||||
(r: any) => r.isCurrent
|
||||
);
|
||||
const corrRevisions =
|
||||
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
|
||||
const currentCorrRev = corrRevisions.find((r) => r.isCurrent);
|
||||
if (!currentCorrRev || !currentCorrRev.rfaRevision)
|
||||
throw new NotFoundException('Current revision not found');
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export class SearchService implements OnModuleInit {
|
||||
|
||||
constructor(
|
||||
private readonly esService: ElasticsearchService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly configService: ConfigService
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
@@ -34,6 +34,7 @@ export class SearchService implements OnModuleInit {
|
||||
mappings: {
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
uuid: { type: 'keyword' }, // ADR-019: public identifier
|
||||
type: { type: 'keyword' }, // correspondence, rfa, drawing
|
||||
docNumber: { type: 'text' },
|
||||
title: { type: 'text', analyzer: 'standard' },
|
||||
@@ -60,12 +61,12 @@ export class SearchService implements OnModuleInit {
|
||||
try {
|
||||
return await this.esService.index({
|
||||
index: this.indexName,
|
||||
id: `${doc.type}_${doc.id}`, // Unique ID: rfa_101
|
||||
id: doc.uuid ? `${doc.type}_${doc.uuid}` : `${doc.type}_${doc.id}`, // ADR-019: prefer UUID key
|
||||
document: doc, // ✅ Library รุ่นใหม่ใช้ 'document' แทน 'body' ในบางเวอร์ชัน
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to index document: ${(error as Error).message}`,
|
||||
`Failed to index document: ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -81,7 +82,7 @@ export class SearchService implements OnModuleInit {
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to remove document: ${(error as Error).message}`,
|
||||
`Failed to remove document: ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,18 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { TransmittalPurpose } from './create-transmittal.dto';
|
||||
|
||||
export class SearchTransmittalDto {
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
@IsNotEmpty()
|
||||
projectId!: number; // บังคับระบุ Project
|
||||
@IsUUID('all')
|
||||
@IsOptional()
|
||||
projectUuid?: string; // ADR-019: Public UUID of the project
|
||||
|
||||
/** @internal Resolved INT ID — set by controller, do NOT expose in API */
|
||||
projectId?: number;
|
||||
|
||||
@IsEnum(TransmittalPurpose)
|
||||
@IsOptional()
|
||||
|
||||
@@ -5,39 +5,59 @@ import {
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { TransmittalService } from './transmittal.service';
|
||||
import { CreateTransmittalDto } from './dto/create-transmittal.dto';
|
||||
import { SearchTransmittalDto } from './dto/search-transmittal.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RbacGuard } from '../../common/guards/rbac.guard';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
|
||||
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
|
||||
import { ProjectService } from '../project/project.service';
|
||||
|
||||
@ApiTags('Transmittals')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@Controller('transmittals')
|
||||
export class TransmittalController {
|
||||
constructor(private readonly transmittalService: TransmittalService) {}
|
||||
constructor(
|
||||
private readonly transmittalService: TransmittalService,
|
||||
private readonly projectService: ProjectService
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new Transmittal' })
|
||||
@RequirePermission('document.create')
|
||||
create(@Body() createDto: CreateTransmittalDto, @CurrentUser() user: User) {
|
||||
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
|
||||
@RequirePermission('document.view')
|
||||
async findAll(
|
||||
@Query() searchDto: SearchTransmittalDto,
|
||||
@CurrentUser() _user: User
|
||||
) {
|
||||
// ADR-019: resolve projectUuid → internal INT projectId if needed
|
||||
if (searchDto.projectUuid) {
|
||||
const project = await this.projectService.findOneByUuid(
|
||||
searchDto.projectUuid
|
||||
);
|
||||
searchDto.projectId = project.id;
|
||||
}
|
||||
return this.transmittalService.findAll(searchDto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Get(':uuid')
|
||||
@ApiOperation({ summary: 'Get Transmittal details' })
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.transmittalService.findOne(id);
|
||||
@ApiParam({ name: 'uuid', description: 'Transmittal UUID (from correspondences.uuid)' })
|
||||
@RequirePermission('document.view')
|
||||
findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
|
||||
return this.transmittalService.findOneByUuid(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CorrespondenceStatus } from '../correspondence/entities/correspondence-
|
||||
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';
|
||||
|
||||
@@ -21,6 +22,7 @@ import { SearchModule } from '../search/search.module';
|
||||
CorrespondenceStatus,
|
||||
]),
|
||||
DocumentNumberingModule,
|
||||
ProjectModule,
|
||||
UserModule,
|
||||
SearchModule,
|
||||
],
|
||||
|
||||
@@ -9,7 +9,11 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { Transmittal } from './entities/transmittal.entity';
|
||||
import { TransmittalItem } from './entities/transmittal-item.entity';
|
||||
import { CreateTransmittalDto } from './dto/create-transmittal.dto';
|
||||
import {
|
||||
CreateTransmittalDto,
|
||||
TransmittalItemDto,
|
||||
} from './dto/create-transmittal.dto';
|
||||
import { SearchTransmittalDto } from './dto/search-transmittal.dto';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { DocumentNumberingService } from '../document-numbering/services/document-numbering.service';
|
||||
import { Correspondence } from '../correspondence/entities/correspondence.entity';
|
||||
@@ -125,14 +129,7 @@ export class TransmittalService {
|
||||
|
||||
// 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) =>
|
||||
const items = createDto.items.map((item: TransmittalItemDto) =>
|
||||
queryRunner.manager.create(TransmittalItem, {
|
||||
transmittalId: savedCorr.id,
|
||||
itemCorrespondenceId: item.itemId, // Direct mapping forced by Schema
|
||||
@@ -160,6 +157,21 @@ export class TransmittalService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ADR-019: Find Transmittal by parent Correspondence UUID (public identifier).
|
||||
* Resolves correspondence.uuid → internal correspondenceId (INT)
|
||||
*/
|
||||
async findOneByUuid(uuid: string) {
|
||||
const correspondence = await this.dataSource.manager.findOne(
|
||||
Correspondence,
|
||||
{ where: { uuid }, select: ['id'] }
|
||||
);
|
||||
if (!correspondence) {
|
||||
throw new NotFoundException(`Transmittal with UUID ${uuid} not found`);
|
||||
}
|
||||
return this.findOne(correspondence.id);
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const transmittal = await this.transmittalRepo.findOne({
|
||||
where: { correspondenceId: id },
|
||||
@@ -170,9 +182,9 @@ export class TransmittalService {
|
||||
return transmittal;
|
||||
}
|
||||
|
||||
async findAll(query: any) {
|
||||
async findAll(query: SearchTransmittalDto) {
|
||||
const { page = 1, limit = 20, projectId, search } = query;
|
||||
const skip = (page - 1) * limit;
|
||||
const skip = ((page ?? 1) - 1) * (limit ?? 20);
|
||||
|
||||
const queryBuilder = this.transmittalRepo
|
||||
.createQueryBuilder('transmittal')
|
||||
@@ -205,8 +217,14 @@ export class TransmittalService {
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
// ADR-019: Map correspondence.uuid to top level for frontend convenience
|
||||
const mappedItems = items.map((t) => ({
|
||||
...t,
|
||||
uuid: t.correspondence?.uuid,
|
||||
}));
|
||||
|
||||
return {
|
||||
data: items,
|
||||
data: mappedItems,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
|
||||
Reference in New Issue
Block a user