5977e48e38
- fix(transmittal): guard duplicate workflow instance on submit() - fix(workflow-guard): add organizationId to context so Level-2 RBAC works - fix(circulation): organizationId context passed relation object not INT FK - fix(transmittal): require Idempotency-Key header on POST submit endpoint - fix(workflow): userId non-optional in processTransition controller - fix(circulation): auto-close counts PENDING and IN_PROGRESS tasks - fix(transmittal): status badge uses workflowState/DRAFT not purpose field - fix(workflow): log cache invalidation failures instead of swallowing - fix(workflow): implement getAvailableActions endpoint stub - fix(i18n): add removeFile key to EN/TH locales
166 lines
5.1 KiB
TypeScript
166 lines
5.1 KiB
TypeScript
// File: src/modules/circulation/circulation-workflow.service.ts
|
|
|
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { DataSource, Repository } from 'typeorm';
|
|
|
|
// Modules
|
|
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
|
|
|
|
// Entities
|
|
import { CirculationStatusCode } from './entities/circulation-status-code.entity';
|
|
import { Circulation } from './entities/circulation.entity';
|
|
|
|
// DTOs
|
|
import { WorkflowTransitionDto } from '../workflow-engine/dto/workflow-transition.dto';
|
|
|
|
@Injectable()
|
|
export class CirculationWorkflowService {
|
|
private readonly logger = new Logger(CirculationWorkflowService.name);
|
|
private readonly WORKFLOW_CODE = 'CIRCULATION_FLOW_V1';
|
|
|
|
constructor(
|
|
private readonly workflowEngine: WorkflowEngineService,
|
|
@InjectRepository(Circulation)
|
|
private readonly circulationRepo: Repository<Circulation>,
|
|
@InjectRepository(CirculationStatusCode)
|
|
private readonly statusRepo: Repository<CirculationStatusCode>,
|
|
private readonly dataSource: DataSource
|
|
) {}
|
|
|
|
/**
|
|
* เริ่มต้นใบเวียน (Start Circulation)
|
|
* ปกติจะเริ่มเมื่อสร้าง Circulation หรือเมื่อกดส่ง
|
|
*/
|
|
async startCirculation(circulationId: number, userId: number) {
|
|
const queryRunner = this.dataSource.createQueryRunner();
|
|
await queryRunner.connect();
|
|
await queryRunner.startTransaction();
|
|
|
|
try {
|
|
const circulation = await this.circulationRepo.findOne({
|
|
where: { id: circulationId },
|
|
});
|
|
|
|
if (!circulation) {
|
|
throw new NotFoundException(
|
|
`Circulation ID ${circulationId} not found`
|
|
);
|
|
}
|
|
|
|
// Context — Circulation เป็น internal document ระดับ Organization (ไม่ผูก contract)
|
|
// Guard Level 2 ตรวจ organizationId; Level 2.5 (contract check) จะ skip เมื่อ contractId = null
|
|
const context: Record<string, unknown> = {
|
|
organizationId: circulation.organizationId,
|
|
creatorId: userId,
|
|
};
|
|
|
|
// Create Instance (Entity Type = 'circulation')
|
|
const instance = await this.workflowEngine.createInstance(
|
|
this.WORKFLOW_CODE,
|
|
'circulation',
|
|
circulation.id.toString(),
|
|
context
|
|
);
|
|
|
|
// Auto start (OPEN -> IN_REVIEW)
|
|
const transitionResult = await this.workflowEngine.processTransition(
|
|
instance.id,
|
|
'START',
|
|
userId,
|
|
'Start Circulation Process',
|
|
{}
|
|
);
|
|
|
|
// Sync Status
|
|
await this.syncStatus(
|
|
circulation,
|
|
transitionResult.nextState,
|
|
queryRunner
|
|
);
|
|
|
|
await queryRunner.commitTransaction();
|
|
|
|
return {
|
|
instanceId: instance.id,
|
|
currentState: transitionResult.nextState,
|
|
};
|
|
} catch (error) {
|
|
await queryRunner.rollbackTransaction();
|
|
this.logger.error(`Failed to start circulation: ${String(error)}`);
|
|
throw error;
|
|
} finally {
|
|
await queryRunner.release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* รับทราบ/ดำเนินการในใบเวียน (Acknowledge / Action)
|
|
*/
|
|
async processAction(
|
|
instanceId: string,
|
|
userId: number,
|
|
dto: WorkflowTransitionDto
|
|
) {
|
|
// ส่งให้ Engine
|
|
const result = await this.workflowEngine.processTransition(
|
|
instanceId,
|
|
dto.action,
|
|
userId,
|
|
dto.comment,
|
|
dto.payload
|
|
);
|
|
|
|
// Sync Status กลับ
|
|
const instance = await this.workflowEngine.getInstanceById(instanceId);
|
|
if (instance && instance.entityType === 'circulation') {
|
|
const circulation = await this.circulationRepo.findOne({
|
|
where: { id: Number(instance.entityId) },
|
|
});
|
|
if (circulation) {
|
|
await this.syncStatus(circulation, result.nextState);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Helper: Map Workflow State -> Circulation Status (OPEN, IN_REVIEW, COMPLETED)
|
|
*/
|
|
private async syncStatus(
|
|
circulation: Circulation,
|
|
workflowState: string,
|
|
queryRunner?: import('typeorm').QueryRunner
|
|
) {
|
|
const statusMap: Record<string, string> = {
|
|
DRAFT: 'OPEN',
|
|
ROUTING: 'IN_REVIEW',
|
|
COMPLETED: 'COMPLETED',
|
|
CANCELLED: 'CANCELLED',
|
|
};
|
|
|
|
const targetCode = statusMap[workflowState] || 'IN_REVIEW';
|
|
|
|
// เนื่องจาก circulation เก็บ status_code เป็น String ในตารางเลย (ตาม Schema v1.4.4)
|
|
// หรืออาจเป็น Relation ID ขึ้นอยู่กับ Implementation จริง
|
|
// สมมติว่าเป็น String Code ตาม Schema:
|
|
|
|
circulation.statusCode = targetCode;
|
|
|
|
// ถ้าจบแล้ว ให้ลงเวลาปิด
|
|
if (targetCode === 'COMPLETED') {
|
|
circulation.closedAt = new Date();
|
|
}
|
|
|
|
const manager = queryRunner
|
|
? queryRunner.manager
|
|
: this.circulationRepo.manager;
|
|
await manager.save(circulation);
|
|
|
|
this.logger.log(
|
|
`Synced Circulation #${circulation.id}: State=${workflowState} -> Status=${targetCode}`
|
|
);
|
|
}
|
|
}
|