Second slice of the Crewing module per wiki Crewing-Implementation-Spec §12 (build order item 2). Everything stays behind NEXT_PUBLIC_CREWING_ENABLED; production is unchanged. Schema is added incrementally — this lands the requisition lifecycle layer. What's in - Schema: Requisition (OPEN→SHORTLISTING→PROPOSING→INTERVIEWING→SELECTED→FILLED, →CANCELLED), ReliefRequest, CrewAction (the POAction mirror) + their enums. Migration crewing_requisitions. - State machine: lib/requisition-state-machine.ts mirrors po-state-machine (selection Manager-only; orthogonal cancel from OPEN/SHORTLISTING by cancel_requisition holders, §6). Codes REQ-9000… via lib/requisition-number.ts. - Actions: raise/cancel/transition + requestReliefCover/convertReliefToRequisition, each guarding flag+permission+state, writing a CrewAction and notifying. Shared autoRaiseRequisition() (lib/requisition-service.ts) is the backfill entry point for sign-off / leave-clash (later phases). - Notifier: notifyCrew() PO-independent path + CrewNotificationEvent. - Screens: /crewing/requisitions (list + Raise modal + relief convert) and /crewing/requisitions/[id] (detail). Requisitions added to the flag-gated Crewing sidebar (Manager + MPO, §7). Tests & docs - Unit: requisition-state-machine.test.ts (11). - Integration: requisitions.test.ts (15) — raise/cancel/transition, relief request + convert, auto-raise, permission gating. - CLAUDE.md "Crewing" section updated with the Phase 2 surface. Deferred: sign-off/experience (Epic K, §12 item 2) depends on the crew/assignment models from Phase 3/4; autoRaiseRequisition() is ready for it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
108 lines
3.6 KiB
TypeScript
108 lines
3.6 KiB
TypeScript
/**
|
|
* Requisition service helpers shared by the crewing server actions and by the
|
|
* system auto-raise paths (sign-off / end-of-contract / leave-clash backfill,
|
|
* Phase 3/4). Kept out of the "use server" action module so non-action code can
|
|
* import the auto-raise helper. See Crewing-Implementation-Spec §5.2/§5.3 (R6).
|
|
*/
|
|
|
|
import { db } from "@/lib/db";
|
|
import { generateRequisitionCode } from "@/lib/requisition-number";
|
|
import { notifyCrew } from "@/lib/notifier";
|
|
import type { Prisma, RequisitionReason, User } from "@prisma/client";
|
|
|
|
type Tx = Prisma.TransactionClient;
|
|
|
|
export interface NewRequisitionInput {
|
|
rankId: string;
|
|
vesselId?: string | null;
|
|
siteId?: string | null;
|
|
reason: RequisitionReason;
|
|
neededBy?: Date | null;
|
|
notes?: string | null;
|
|
raisedById?: string | null; // null = system-raised
|
|
autoRaised?: boolean;
|
|
}
|
|
|
|
type RequisitionWithRefs = Prisma.RequisitionGetPayload<{
|
|
include: { rank: true; vessel: true; site: true };
|
|
}>;
|
|
|
|
/**
|
|
* Core requisition creator — run inside a transaction. Generates the code and
|
|
* writes the REQUISITION_RAISED CrewAction. Callers own notification + any
|
|
* relief-request linking afterwards.
|
|
*/
|
|
export async function createRequisitionTx(
|
|
tx: Tx,
|
|
input: NewRequisitionInput
|
|
): Promise<RequisitionWithRefs> {
|
|
const code = await generateRequisitionCode(tx);
|
|
return tx.requisition.create({
|
|
data: {
|
|
code,
|
|
reason: input.reason,
|
|
autoRaised: input.autoRaised ?? false,
|
|
neededBy: input.neededBy ?? null,
|
|
notes: input.notes ?? null,
|
|
rankId: input.rankId,
|
|
vesselId: input.vesselId ?? null,
|
|
siteId: input.siteId ?? null,
|
|
raisedById: input.raisedById ?? null,
|
|
actions: {
|
|
create: {
|
|
actionType: "REQUISITION_RAISED",
|
|
actorId: input.raisedById ?? null,
|
|
metadata: input.autoRaised ? { auto: true, reason: input.reason } : undefined,
|
|
},
|
|
},
|
|
},
|
|
include: { rank: true, vessel: true, site: true },
|
|
});
|
|
}
|
|
|
|
/** Human label for a requisition's cost axis (vessel preferred, else site). */
|
|
export function requisitionLocationLabel(r: {
|
|
vessel: { name: string } | null;
|
|
site: { name: string } | null;
|
|
}): string {
|
|
return r.vessel?.name ?? r.site?.name ?? "—";
|
|
}
|
|
|
|
/** Office recipients (MPO sources recruitment; Manager oversees). */
|
|
export function getOfficeRecipients(): Promise<User[]> {
|
|
return db.user.findMany({
|
|
where: { isActive: true, role: { in: ["MANNING", "MANAGER", "SUPERUSER"] } },
|
|
});
|
|
}
|
|
|
|
/** MPO recipients — for "requisition raised → MPO" (spec §11). */
|
|
export function getMpoRecipients(): Promise<User[]> {
|
|
return db.user.findMany({
|
|
where: { isActive: true, role: { in: ["MANNING", "SUPERUSER"] } },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* System auto-raise: an OPEN requisition with no human actor (autoRaised), then
|
|
* notifies the office. Sign-off, end-of-contract and the leave-clash detector
|
|
* (later phases) all funnel through here. See spec §5.2/§5.3 (R6).
|
|
*/
|
|
export async function autoRaiseRequisition(
|
|
input: Omit<NewRequisitionInput, "raisedById" | "autoRaised">
|
|
): Promise<RequisitionWithRefs> {
|
|
const requisition = await db.$transaction((tx) =>
|
|
createRequisitionTx(tx, { ...input, raisedById: null, autoRaised: true })
|
|
);
|
|
|
|
const recipients = await getOfficeRecipients();
|
|
const loc = requisitionLocationLabel(requisition);
|
|
await notifyCrew({
|
|
event: "REQUISITION_RAISED",
|
|
recipients,
|
|
subject: `Requisition ${requisition.code} auto-raised`,
|
|
body: `A ${requisition.rank.name} vacancy on ${loc} was auto-raised (${requisition.code}) — reason: ${requisition.reason}.`,
|
|
link: `/crewing/requisitions/${requisition.id}`,
|
|
});
|
|
|
|
return requisition;
|
|
}
|