pelagia-portal/App/pelagia-portal/lib/po-state-machine.ts
Hardik 3b3a26eafe feat(receipt): allow partial receipt confirmation with per-item delivery tracking
Submitters can now mark individual item quantities as received when
confirming delivery, rather than treating a PO as all-or-nothing.

Schema (migration: 20260516103013_partial_receipt):
- POStatus: new PARTIALLY_CLOSED value between PAID_DELIVERED and CLOSED
- ActionType: new PARTIAL_RECEIPT_CONFIRMED value
- POLineItem: new deliveredQuantity Decimal? field — accumulates delivered qty
  across multiple receipt events

State machine:
- PAID_DELIVERED → confirm_partial_receipt → PARTIALLY_CLOSED (new)
- PARTIALLY_CLOSED → confirm_receipt → CLOSED (all delivered)
- PARTIALLY_CLOSED → confirm_partial_receipt → PARTIALLY_CLOSED (more partial)

Receipt page / form:
- Loads line items with ordered qty, previously delivered qty, and remaining qty
- Per-row numeric input for "receiving now" defaulting to all remaining
- "Mark all remaining" shortcut
- Dynamic button: "Confirm Partial Receipt" vs "Confirm Receipt & Close PO"
- Info banner telling user if the PO will stay open or close

Receipt action:
- Accumulates deliveredQuantity per line item
- If all lines fully delivered → CLOSED + fires notifications + updates inventory
- If any line still outstanding → PARTIALLY_CLOSED (no notifications yet)
- Inventory auto-update runs per-event for the delivered quantities only

Dashboard & PO detail:
- Open Orders count now includes PARTIALLY_CLOSED
- "Confirm Receipt" CTA in po-detail handles PARTIALLY_CLOSED with
  distinct amber styling and "Confirm Remaining" label
- Activity log shows PARTIAL_RECEIPT_CONFIRMED with appropriate label
- PARTIALLY_CLOSED gets warning (amber) badge variant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 16:02:44 +05:30

156 lines
4.1 KiB
TypeScript

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"
| "confirm_partial_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", "MANAGER", "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", "ACCOUNTS", "MANAGER", "SUPERUSER"],
requiresNote: false,
sideEffects: ["EMAIL_MANAGER"],
},
},
EDITS_REQUESTED: {
submit: {
to: "SUBMITTED",
allowedRoles: ["TECHNICAL", "MANNING", "MANAGER", "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"],
},
confirm_partial_receipt: {
to: "PARTIALLY_CLOSED",
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"],
requiresNote: false,
sideEffects: [],
},
},
PARTIALLY_CLOSED: {
confirm_receipt: {
to: "CLOSED",
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"],
requiresNote: false,
sideEffects: ["EMAIL_MANAGER", "EMAIL_ACCOUNTS"],
},
confirm_partial_receipt: {
to: "PARTIALLY_CLOSED",
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"],
requiresNote: false,
sideEffects: [],
},
},
};
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;
}