Second slice of Phase 3 (stacked on 3a candidates). The gated 7-stage recruitment pipeline per Crewing-Implementation-Spec §5.1/§8.4–8.5/§8.13. Behind NEXT_PUBLIC_CREWING_ENABLED; production unchanged. What's in - Schema (crewing_pipeline migration): Application (one per requisition+candidate) + 7-stage ApplicationStage; ApplicationGate (SALARY/SELECTION/WAIVER pending = Manager queue items); ReferenceCheck; effective-dated SalaryStructure (attached to the Application now, bound to the assignment in 3c); minimal BankDetail/EpfDetail captured at DOC_VERIFICATION (PII encryption deferred to Phase 4). CrewAction += applicationId; pipeline CrewActionTypes. - State machine: lib/application-pipeline.ts — sourcing advances MPO/Manager; approve_salary + select are Manager-only; orthogonal canReject; BOARD_STAGES. - Actions: addApplication (first candidate → requisition SHORTLISTING), advanceStage, recordReferenceCheck, verifyDocuments (bank/EPF), agreeSalary→approveSalary/returnSalary, recordInterviewResult, requestInterviewWaiver→approve/decline, selectCandidate (→ requisition SELECTED)/returnSelection, rejectApplication. Waiver never automatic (R2). Notifications SALARY/SELECTION/WAIVER + CANDIDATE_PROPOSED. - Screens: pipeline board per requisition (7 columns + Add candidate); application workhorse (7-step stepper + adaptive per-stage action card); "Open pipeline" on the requisition detail. Central /approvals gains a crewing section (inline Approve/Return) for one unified Manager queue (§8.13 R8). Tests & docs - Unit: application-pipeline.test.ts (9). Integration: applications.test.ts (10) — full happy path, salary/selection/waiver approvals + Manager-only gating, failed interview, reject, site-staff lockout. type-check clean; full unit (234) + integration (163) green. - CLAUDE.md "Crewing" updated with the Phase 3b surface. Deferred: onboarding (Epic D, Phase 3c) — SELECTED → ONBOARDED, CrewAssignment, employeeId, requisition → FILLED, salary bound to the assignment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
115 lines
3.9 KiB
TypeScript
115 lines
3.9 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"] } },
|
|
});
|
|
}
|
|
|
|
/** Manager recipients — for the approval gates (salary / selection / waiver). */
|
|
export function getManagerRecipients(): Promise<User[]> {
|
|
return db.user.findMany({
|
|
where: { isActive: true, role: { in: ["MANAGER", "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;
|
|
}
|