Membangun Workflow Engine untuk Persetujuan Multi-Langkah: NestJS, PostgreSQL, dan React

Foto oleh Unsplash

Foto oleh Unsplash
Setiap proses bisnis yang melibatkan persetujuan — permintaan cuti, purchase order, voucher pembayaran — pada dasarnya adalah state machine. Ketika saya membangun ERP untuk Commsult Indonesia, saya mengekstrak workflow engine generik yang menggerakkan semua alur persetujuan dalam sistem.
Alternatif dari engine generik adalah menulis logika persetujuan kustom untuk setiap proses bisnis. Engine generik berarti Anda memperbarui tabel transisi sekali, dan semua proses yang berbagi konfigurasi itu segera mendapat manfaat.
State machine untuk alur kerja bisnis membutuhkan empat hal: sekumpulan state yang terbatas, sekumpulan aksi yang terbatas, tabel yang memetakan (from_state, action, required_role) → to_state, dan cara untuk menolak transisi yang tidak valid.
Setiap transisi state ditambahkan ke kolom JSONB bernama 'history' di tabel workflow_instances. Ini memberikan catatan lengkap dan terurut tentang siapa yang melakukan apa dan kapan — tanpa memerlukan tabel riwayat terpisah.
Multi-Step Approval Workflow State Machine
┌───────────┐ submit() ┌───────────────┐
│ DRAFT │──────────────►│ PENDING_L1 │
└───────────┘ │ (Mgr Approval)│
└───────┬───────┘
│
┌─────────────────┼──────────────────┐
│ approve() │ │ reject()
▼ │ ▼
┌────────────────┐ │ ┌───────────────┐
│ PENDING_L2 │ │ │ REJECTED │
│ (Dir Approval) │ │ └───────────────┘
└───────┬────────┘ │
│ │ revise()
┌───────────┼──────────┐ ▼
│ approve() │ │ ┌────────────┐
▼ │ reject() │ │ REVISION │──► back to DRAFT
┌──────────┐ ▼ │ └────────────┘
│PENDING_FIN│ ┌───────────┐│
│(Finance) │ │ REJECTED ││
└────┬─────┘ └───────────┘│
│ │
│ approve() │
▼
┌──────────────┐
│ APPROVED │──► Trigger downstream actions
└──────────────┘ (email, PDF, payment run)
PostgreSQL table: workflow_instances
Columns: id, entity_type, entity_id, current_state,
history JSONB, created_at, updated_atGunakan gen_random_uuid() PostgreSQL untuk ID instance workflow, dan simpan entity_type + entity_id secara terpisah. Ini membuat workflow engine benar-benar generik — WorkflowService yang sama menggerakkan permintaan cuti, voucher AP, dan proses masa depan apa pun.
WorkflowService memiliki satu metode publik: transition(instanceId, action, actorRole, comment). Ia memuat instance workflow, menemukan transisi yang cocok, memvalidasi peran aktor, menerapkan perubahan state, menambahkan ke riwayat JSONB, dan memancarkan event domain untuk handler downstream.
Komponen React untuk state workflow memiliki dua bagian: lencana status dengan kode warna, dan timeline yang dapat dilipat menampilkan riwayat transisi lengkap dengan aktor, aksi, cap waktu, dan komentar opsional.
// NestJS: Generic workflow state machine service
// Works for Leave Requests, AP Vouchers, AR Invoices etc.
export type WorkflowState =
| 'DRAFT' | 'PENDING_L1' | 'PENDING_L2' | 'PENDING_FINANCE'
| 'APPROVED' | 'REJECTED' | 'REVISION';
export type WorkflowAction =
| 'submit' | 'approve' | 'reject' | 'revise' | 'cancel';
interface Transition {
from: WorkflowState;
action: WorkflowAction;
to: WorkflowState;
requiredRole: string;
}
const TRANSITIONS: Transition[] = [
{ from: 'DRAFT', action: 'submit', to: 'PENDING_L1', requiredRole: 'EMPLOYEE' },
{ from: 'PENDING_L1', action: 'approve', to: 'PENDING_L2', requiredRole: 'MANAGER' },
{ from: 'PENDING_L1', action: 'reject', to: 'REJECTED', requiredRole: 'MANAGER' },
{ from: 'PENDING_L1', action: 'revise', to: 'REVISION', requiredRole: 'MANAGER' },
{ from: 'PENDING_L2', action: 'approve', to: 'PENDING_FINANCE', requiredRole: 'DIRECTOR' },
{ from: 'PENDING_L2', action: 'reject', to: 'REJECTED', requiredRole: 'DIRECTOR' },
{ from: 'PENDING_FINANCE', action: 'approve', to: 'APPROVED', requiredRole: 'FINANCE' },
{ from: 'PENDING_FINANCE', action: 'reject', to: 'REJECTED', requiredRole: 'FINANCE' },
{ from: 'REVISION', action: 'submit', to: 'PENDING_L1', requiredRole: 'EMPLOYEE' },
];
@Injectable()
export class WorkflowService {
constructor(
@InjectRepository(WorkflowInstance)
private workflowRepo: Repository<WorkflowInstance>,
private eventEmitter: EventEmitter2,
) {}
async transition(
instanceId: string,
action: WorkflowAction,
actorRole: string,
comment?: string,
): Promise<WorkflowInstance> {
const instance = await this.workflowRepo.findOneOrFail({ where: { id: instanceId } });
const transition = TRANSITIONS.find(
t => t.from === instance.currentState
&& t.action === action
&& t.requiredRole === actorRole,
);
if (!transition) {
throw new ForbiddenException(
`Action "${action}" not allowed from state "${instance.currentState}" for role "${actorRole}"`
);
}
// Append to JSONB history
const historyEntry = {
from: instance.currentState,
to: transition.to,
action,
actorRole,
comment,
timestamp: new Date().toISOString(),
};
await this.workflowRepo.update(instanceId, {
currentState: transition.to,
history: () => `history || '${JSON.stringify(historyEntry)}'::jsonb`,
});
// Emit event for downstream handlers (email, notifications)
this.eventEmitter.emit(`workflow.${transition.to.toLowerCase()}`, {
instanceId,
entityType: instance.entityType,
entityId: instance.entityId,
});
return this.workflowRepo.findOneOrFail({ where: { id: instanceId } });
}
}Alur persetujuan dunia nyata memiliki dua kasus tepi: permintaan revisi dan pembatalan. Keduanya membutuhkan state mereka sendiri — REVISION dan CANCELLED. State REVISION memungkinkan pengirim asli untuk memperbarui dokumen dan mengirim ulang.
Jangan mencoba membuat workflow engine berbasis konfigurasi dari UI database di versi pertama Anda. Mulai dengan tabel transisi TypeScript yang dikodekan keras, buat engine bekerja dengan andal, kemudian tambahkan fitur konfigurasi hanya ketika pengguna benar-benar memintanya.
Workflow engine adalah jantung ERP — bug di sini mempengaruhi setiap proses persetujuan dalam sistem. Tulis unit test yang komprehensif untuk setiap transisi yang valid dan tidak valid. Gunakan test.each Jest untuk mencakup semua transisi tanpa kode test yang berulang.
Setelah 12 bulan di produksi: gunakan optimistic locking pada workflow_instances untuk mencegah konflik transisi bersamaan; simpan identitas aktor lengkap dalam riwayat JSONB; dan tambahkan fitur 'delegasi' lebih awal untuk manajer yang sedang cuti.