feat(state-machine): PO lifecycle state machine with role-gated transitions
10 statuses, 11 transitions. Each transition declares allowedRoles, requiresNote flag and sideEffects (which email groups to notify). Helpers: getTransition, canPerformAction, getAvailableActions, requiresNote.
This commit is contained in:
parent
043b26921a
commit
c67afb2fff
1 changed files with 135 additions and 0 deletions
135
App/pelagia-portal/lib/po-state-machine.ts
Normal file
135
App/pelagia-portal/lib/po-state-machine.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import type { POStatus, Role } from "@prisma/client";
|
||||
|
||||
export type POAction =
|
||||
| "submit"
|
||||
| "approve"
|
||||
| "approve_with_note"
|
||||
| "reject"
|
||||
| "request_edits"
|
||||
| "request_vendor_id"
|
||||
| "provide_vendor_id"
|
||||
| "process_payment"
|
||||
| "mark_paid"
|
||||
| "confirm_receipt";
|
||||
|
||||
export type SideEffect =
|
||||
| "EMAIL_MANAGER"
|
||||
| "EMAIL_SUBMITTER"
|
||||
| "EMAIL_ACCOUNTS"
|
||||
| "EMAIL_SUBMITTER_AND_MANAGER";
|
||||
|
||||
interface Transition {
|
||||
to: POStatus;
|
||||
allowedRoles: Role[];
|
||||
requiresNote: boolean;
|
||||
sideEffects: SideEffect[];
|
||||
}
|
||||
|
||||
type TransitionMap = Partial<Record<POAction, Transition>>;
|
||||
|
||||
const TRANSITIONS: Partial<Record<POStatus, TransitionMap>> = {
|
||||
DRAFT: {
|
||||
submit: {
|
||||
to: "SUBMITTED",
|
||||
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"],
|
||||
requiresNote: false,
|
||||
sideEffects: ["EMAIL_MANAGER"],
|
||||
},
|
||||
},
|
||||
SUBMITTED: {
|
||||
// Auto-advances to MGR_REVIEW in the server action immediately after SUBMITTED
|
||||
},
|
||||
MGR_REVIEW: {
|
||||
approve: {
|
||||
to: "MGR_APPROVED",
|
||||
allowedRoles: ["MANAGER", "SUPERUSER"],
|
||||
requiresNote: false,
|
||||
sideEffects: ["EMAIL_SUBMITTER", "EMAIL_ACCOUNTS"],
|
||||
},
|
||||
approve_with_note: {
|
||||
to: "MGR_APPROVED",
|
||||
allowedRoles: ["MANAGER", "SUPERUSER"],
|
||||
requiresNote: true,
|
||||
sideEffects: ["EMAIL_SUBMITTER", "EMAIL_ACCOUNTS"],
|
||||
},
|
||||
reject: {
|
||||
to: "REJECTED",
|
||||
allowedRoles: ["MANAGER", "SUPERUSER"],
|
||||
requiresNote: true,
|
||||
sideEffects: ["EMAIL_SUBMITTER"],
|
||||
},
|
||||
request_edits: {
|
||||
to: "EDITS_REQUESTED",
|
||||
allowedRoles: ["MANAGER", "SUPERUSER"],
|
||||
requiresNote: true,
|
||||
sideEffects: ["EMAIL_SUBMITTER"],
|
||||
},
|
||||
request_vendor_id: {
|
||||
to: "VENDOR_ID_PENDING",
|
||||
allowedRoles: ["MANAGER", "SUPERUSER"],
|
||||
requiresNote: false,
|
||||
sideEffects: ["EMAIL_SUBMITTER"],
|
||||
},
|
||||
},
|
||||
VENDOR_ID_PENDING: {
|
||||
provide_vendor_id: {
|
||||
to: "MGR_REVIEW",
|
||||
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"],
|
||||
requiresNote: false,
|
||||
sideEffects: ["EMAIL_MANAGER"],
|
||||
},
|
||||
},
|
||||
EDITS_REQUESTED: {
|
||||
submit: {
|
||||
to: "SUBMITTED",
|
||||
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"],
|
||||
requiresNote: false,
|
||||
sideEffects: ["EMAIL_MANAGER"],
|
||||
},
|
||||
},
|
||||
MGR_APPROVED: {
|
||||
process_payment: {
|
||||
to: "SENT_FOR_PAYMENT",
|
||||
allowedRoles: ["ACCOUNTS", "SUPERUSER"],
|
||||
requiresNote: false,
|
||||
sideEffects: ["EMAIL_SUBMITTER_AND_MANAGER"],
|
||||
},
|
||||
},
|
||||
SENT_FOR_PAYMENT: {
|
||||
mark_paid: {
|
||||
to: "PAID_DELIVERED",
|
||||
allowedRoles: ["ACCOUNTS", "SUPERUSER"],
|
||||
requiresNote: false,
|
||||
sideEffects: ["EMAIL_SUBMITTER", "EMAIL_MANAGER"],
|
||||
},
|
||||
},
|
||||
PAID_DELIVERED: {
|
||||
confirm_receipt: {
|
||||
to: "CLOSED",
|
||||
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"],
|
||||
requiresNote: false,
|
||||
sideEffects: ["EMAIL_MANAGER", "EMAIL_ACCOUNTS"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function getTransition(from: POStatus, action: POAction): Transition | null {
|
||||
return TRANSITIONS[from]?.[action] ?? null;
|
||||
}
|
||||
|
||||
export function canPerformAction(from: POStatus, action: POAction, role: Role): boolean {
|
||||
const transition = getTransition(from, action);
|
||||
return transition?.allowedRoles.includes(role) ?? false;
|
||||
}
|
||||
|
||||
export function getAvailableActions(status: POStatus, role: Role): POAction[] {
|
||||
const map = TRANSITIONS[status];
|
||||
if (!map) return [];
|
||||
return (Object.keys(map) as POAction[]).filter((action) =>
|
||||
canPerformAction(status, action, role)
|
||||
);
|
||||
}
|
||||
|
||||
export function requiresNote(from: POStatus, action: POAction): boolean {
|
||||
return getTransition(from, action)?.requiresNote ?? false;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue