diff --git a/backend/src/modules/circulation/circulation-workflow.service.ts b/backend/src/modules/circulation/circulation-workflow.service.ts index 7fe552f..475d699 100644 --- a/backend/src/modules/circulation/circulation-workflow.service.ts +++ b/backend/src/modules/circulation/circulation-workflow.service.ts @@ -51,7 +51,7 @@ export class CirculationWorkflowService { // Context — Circulation เป็น internal document ระดับ Organization (ไม่ผูก contract) // Guard Level 2 ตรวจ organizationId; Level 2.5 (contract check) จะ skip เมื่อ contractId = null const context: Record = { - organizationId: circulation.organization, + organizationId: circulation.organizationId, creatorId: userId, }; diff --git a/backend/src/modules/circulation/circulation.service.ts b/backend/src/modules/circulation/circulation.service.ts index 9b15505..978639a 100644 --- a/backend/src/modules/circulation/circulation.service.ts +++ b/backend/src/modules/circulation/circulation.service.ts @@ -325,12 +325,14 @@ export class CirculationService { await this.routingRepo.save(routing); // Check: ถ้าทุกคนทำเสร็จแล้ว ให้ปิดใบเวียน (Master) - const pendingCount = await this.routingRepo.count({ - where: { - circulationId: routing.circulationId, - status: 'PENDING', // หรือ status ที่ยังไม่เสร็จ - }, - }); + // Bug 5 fix: นับทั้ง PENDING และ IN_PROGRESS — forceClose() ปิดทั้งสองสถานะ + const pendingCount = await this.routingRepo + .createQueryBuilder('r') + .where('r.circulationId = :cid', { cid: routing.circulationId }) + .andWhere('r.status IN (:...statuses)', { + statuses: ['PENDING', 'IN_PROGRESS'], + }) + .getCount(); if (pendingCount === 0) { await this.circulationRepo.update(routing.circulationId, { diff --git a/backend/src/modules/transmittal/transmittal.controller.ts b/backend/src/modules/transmittal/transmittal.controller.ts index 01d92c7..83c86f0 100644 --- a/backend/src/modules/transmittal/transmittal.controller.ts +++ b/backend/src/modules/transmittal/transmittal.controller.ts @@ -1,6 +1,8 @@ import { + BadRequestException, Controller, Get, + Headers, Post, Body, Param, @@ -85,8 +87,12 @@ export class TransmittalController { @Audit('transmittal.submit', 'transmittal') submit( @Param('uuid', ParseUuidPipe) uuid: string, - @CurrentUser() user: User + @CurrentUser() user: User, + @Headers('Idempotency-Key') idempotencyKey: string ) { + if (!idempotencyKey) { + throw new BadRequestException('Idempotency-Key header is required'); + } return this.transmittalService.submit(uuid, user); } } diff --git a/backend/src/modules/transmittal/transmittal.service.ts b/backend/src/modules/transmittal/transmittal.service.ts index d5dcba0..bf20985 100644 --- a/backend/src/modules/transmittal/transmittal.service.ts +++ b/backend/src/modules/transmittal/transmittal.service.ts @@ -252,7 +252,7 @@ export class TransmittalService { Correspondence, { where: { publicId: uuid }, - select: ['id', 'correspondenceNumber', 'disciplineId'], + select: ['id', 'correspondenceNumber', 'disciplineId', 'originatorId'], } ); if (!correspondence) @@ -285,10 +285,17 @@ export class TransmittalService { } } - // เริ่ม Workflow Instance สำหรับ Transmittal - const statusDraft = await this.statusRepo.findOne({ - where: { statusCode: 'DRAFT' }, - }); + // Bug 1 fix: ป้องกัน duplicate instance — ถ้ามี Active Instance อยู่แล้ว ให้หยุด + const existingInstance = await this.workflowEngine.getInstanceByEntity( + 'transmittal', + correspondence.id.toString() + ); + if (existingInstance) { + throw new ValidationException( + `Transmittal นี้ถูก Submit ไปแล้ว (Workflow Instance: ${existingInstance.id})` + ); + } + // [C3] Resolve contractId from discipline for contract-scoped workflow let contractId: number | null = null; if (correspondence.disciplineId) { @@ -298,11 +305,17 @@ export class TransmittalService { ); contractId = rows[0]?.contract_id ?? null; } + + // Bug 2 fix: ใส่ organizationId ใน context เพื่อให้ WorkflowTransitionGuard Level 2 (Org Admin) ทำงานได้ const instance = await this.workflowEngine.createInstance( 'TRANSMITTAL_FLOW_V1', 'transmittal', correspondence.id.toString(), - { ownerId: user.user_id, contractId } + { + ownerId: user.user_id, + contractId, + organizationId: correspondence.originatorId ?? null, + } ); const result = await this.workflowEngine.processTransition( @@ -313,18 +326,16 @@ export class TransmittalService { ); // Sync สถานะกลับที่ Correspondence Revision - if (statusDraft) { - const revision = await this.revisionRepo.findOne({ - where: { correspondenceId: correspondence.id, isCurrent: true }, + const revision = await this.revisionRepo.findOne({ + where: { correspondenceId: correspondence.id, isCurrent: true }, + }); + if (revision) { + const submittedStatus = await this.statusRepo.findOne({ + where: { statusCode: 'SUBMITTED' }, }); - if (revision) { - const submittedStatus = await this.statusRepo.findOne({ - where: { statusCode: 'SUBMITTED' }, - }); - if (submittedStatus) { - revision.statusId = submittedStatus.id; - await this.revisionRepo.save(revision); - } + if (submittedStatus) { + revision.statusId = submittedStatus.id; + await this.revisionRepo.save(revision); } } diff --git a/backend/src/modules/workflow-engine/workflow-engine.controller.ts b/backend/src/modules/workflow-engine/workflow-engine.controller.ts index b340ec8..624eab1 100644 --- a/backend/src/modules/workflow-engine/workflow-engine.controller.ts +++ b/backend/src/modules/workflow-engine/workflow-engine.controller.ts @@ -116,7 +116,7 @@ export class WorkflowEngineController { throw new BadRequestException('Idempotency-Key header is required'); } - const userId = req.user?.user_id; + const userId = req.user.user_id; // ตรวจ Redis ว่า Request นี้ถูกส่งมาแล้วหรือไม่ (key ผูกกับ userId ป้องกัน cross-user replay) const cacheKey = `idempotency:transition:${idempotencyKey}:${userId}`; @@ -154,9 +154,18 @@ export class WorkflowEngineController { @ApiOperation({ summary: 'ดึงรายการปุ่ม Action ที่สามารถกดได้ ณ สถานะปัจจุบัน', }) - @RequirePermission('document.view') // ผู้ที่มีสิทธิ์ดูเอกสาร ควรดู Action ได้ - getAvailableActions(@Param('id') _instanceId: string) { - // Note: Logic การดึง Action ตาม Instance ID จะถูก Implement ใน Task ถัดไป - return { message: 'Pending implementation in Service layer' }; + @ApiParam({ name: 'id', description: 'Workflow Instance ID (UUID)' }) + @RequirePermission('document.view') + async getAvailableActions(@Param('id') instanceId: string) { + const instance = await this.workflowService.getInstanceById(instanceId); + const actions = await this.workflowService.getAvailableActions( + instance.definition.workflow_code, + instance.currentState + ); + return { + instanceId, + currentState: instance.currentState, + availableActions: actions, + }; } } diff --git a/backend/src/modules/workflow-engine/workflow-engine.service.ts b/backend/src/modules/workflow-engine/workflow-engine.service.ts index 1f9a05f..9b07ee4 100644 --- a/backend/src/modules/workflow-engine/workflow-engine.service.ts +++ b/backend/src/modules/workflow-engine/workflow-engine.service.ts @@ -387,7 +387,13 @@ export class WorkflowEngineService { await queryRunner.commitTransaction(); // ADR-021 T043: Invalidate Workflow History cache หลัง transition สำเร็จ - void this.cacheManager.del(`wf:history:${instanceId}`); + this.cacheManager + .del(`wf:history:${instanceId}`) + .catch((e: unknown) => + this.logger.warn( + `Cache invalidation failed for wf:history:${instanceId} — stale data may be served. Error: ${e instanceof Error ? e.message : String(e)}` + ) + ); // [NEW] เก็บค่าไว้ Dispatch หลัง Commit eventsToDispatch = evaluation.events; diff --git a/frontend/app/(dashboard)/transmittals/[uuid]/page.tsx b/frontend/app/(dashboard)/transmittals/[uuid]/page.tsx index e71e353..7b5f08d 100644 --- a/frontend/app/(dashboard)/transmittals/[uuid]/page.tsx +++ b/frontend/app/(dashboard)/transmittals/[uuid]/page.tsx @@ -60,7 +60,9 @@ export default function TransmittalDetailPage() { const transmittalDocNo = transmittal.correspondence?.correspondenceNumber ?? transmittal.transmittalNo ?? ''; const transmittalSubject = transmittal.subject ?? ''; - const transmittalStatus = transmittal.purpose ?? ''; + const transmittalStatus = transmittal.workflowInstanceId + ? (transmittal.workflowState ?? 'SUBMITTED') + : 'DRAFT'; return (
diff --git a/frontend/components/workflow/workflow-lifecycle.tsx b/frontend/components/workflow/workflow-lifecycle.tsx index e00c246..814b1e5 100644 --- a/frontend/components/workflow/workflow-lifecycle.tsx +++ b/frontend/components/workflow/workflow-lifecycle.tsx @@ -294,7 +294,7 @@ export function WorkflowLifecycle({ type="button" className="ml-0.5 hover:text-destructive" onClick={(e) => { e.stopPropagation(); removeUploadedFile(f.publicId); }} - aria-label={t('workflow.timeline.uploadError')} + aria-label={t('workflow.timeline.removeFile')} > diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 1d96dc4..47615e8 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -29,6 +29,7 @@ "workflow.timeline.uploading": "Uploading...", "workflow.timeline.uploadTypes": "PDF, DOCX, XLSX, DWG, ZIP · Max 50 MB", "workflow.timeline.uploadError": "Unable to upload", + "workflow.timeline.removeFile": "Remove file", "filePreview.fallbackTitle": "File", "filePreview.fileUnavailable": "File has been removed from storage.", diff --git a/frontend/public/locales/th/common.json b/frontend/public/locales/th/common.json index e04ea21..774c3ed 100644 --- a/frontend/public/locales/th/common.json +++ b/frontend/public/locales/th/common.json @@ -29,6 +29,7 @@ "workflow.timeline.uploading": "กำลังอัปโหลด...", "workflow.timeline.uploadTypes": "PDF, DOCX, XLSX, DWG, ZIP · สูงสุด 50 MB", "workflow.timeline.uploadError": "ไม่สามารถอัปโหลด", + "workflow.timeline.removeFile": "ลบไฟล์", "filePreview.fallbackTitle": "ไฟล์", "filePreview.fileUnavailable": "ไฟล์ถูกลบออกจาก Storage แล้ว",