feat(crewing): land full Crewing module on master (Phases 1–5 + hardening) #93
2 changed files with 63 additions and 11 deletions
|
|
@ -562,8 +562,33 @@ export async function onboardCandidate(formData: FormData): Promise<ActionResult
|
|||
});
|
||||
if (!app) return { error: "Application not found" };
|
||||
if (app.stage !== "SELECTED") return { error: `Only a SELECTED candidate can be onboarded (currently ${app.stage})` };
|
||||
|
||||
// D1 (spec §8.5): onboarding is blocked until the salary structure is
|
||||
// Manager-approved. Without this guard a SELECTED application that somehow has
|
||||
// no approved structure would still "succeed" but bind zero salary rows
|
||||
// (the updateMany below would match nothing) — a silent payroll gap.
|
||||
const approvedSalary = await db.salaryStructure.findFirst({
|
||||
where: { applicationId: id, approvedById: { not: null }, assignmentId: null },
|
||||
select: { id: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
if (!approvedSalary) return { error: "Salary structure must be Manager-approved before onboarding" };
|
||||
|
||||
const joiningDate = new Date(joiningStr);
|
||||
|
||||
// Upload the optional contract letter BEFORE the transaction (storage I/O),
|
||||
// then persist its row INSIDE the tx so onboarding is one atomic side-effecting
|
||||
// event (spec §11). The blob key is keyed on the crew member (stable before the
|
||||
// assignment exists); if the tx fails we leave only a harmless orphan blob,
|
||||
// never a fully-onboarded crew member with no contract row.
|
||||
const file = formData.get("contract");
|
||||
let contract: { fileKey: string; salaryRestricted: boolean } | null = null;
|
||||
if (file instanceof File && file.size > 0) {
|
||||
const key = buildStorageKey("contract", app.crewMember.id, file.name);
|
||||
await uploadBuffer(key, Buffer.from(await file.arrayBuffer()), file.type || "application/octet-stream");
|
||||
contract = { fileKey: key, salaryRestricted: formData.get("salaryRestricted") !== "false" };
|
||||
}
|
||||
|
||||
const result = await db.$transaction(async (tx) => {
|
||||
const employeeId = await generateEmployeeId(tx);
|
||||
const assignment = await tx.crewAssignment.create({
|
||||
|
|
@ -582,9 +607,23 @@ export async function onboardCandidate(formData: FormData): Promise<ActionResult
|
|||
where: { applicationId: id, approvedById: { not: null }, assignmentId: null },
|
||||
data: { assignmentId: assignment.id, effectiveFrom: joiningDate },
|
||||
});
|
||||
if (contract) {
|
||||
await tx.contractLetter.create({ data: { assignmentId: assignment.id, fileKey: contract.fileKey, salaryRestricted: contract.salaryRestricted } });
|
||||
}
|
||||
// D3 AC2 (spec §11): the single CREW_ONBOARDED audit row records the created IDs.
|
||||
await tx.application.update({
|
||||
where: { id },
|
||||
data: { stage: "ONBOARDED", actions: { create: { actionType: "CREW_ONBOARDED", actorId: g.userId, crewMemberId: app.crewMember.id } } },
|
||||
data: {
|
||||
stage: "ONBOARDED",
|
||||
actions: {
|
||||
create: {
|
||||
actionType: "CREW_ONBOARDED",
|
||||
actorId: g.userId,
|
||||
crewMemberId: app.crewMember.id,
|
||||
metadata: { assignmentId: assignment.id, employeeId, salaryStructureId: approvedSalary.id },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await tx.requisition.update({
|
||||
where: { id: app.requisition.id },
|
||||
|
|
@ -599,16 +638,6 @@ export async function onboardCandidate(formData: FormData): Promise<ActionResult
|
|||
return { assignmentId: assignment.id, employeeId };
|
||||
});
|
||||
|
||||
// Contract letter (optional) — stored after the core transaction.
|
||||
const file = formData.get("contract");
|
||||
if (file instanceof File && file.size > 0) {
|
||||
const key = buildStorageKey("contract", result.assignmentId, file.name);
|
||||
await uploadBuffer(key, Buffer.from(await file.arrayBuffer()), file.type || "application/octet-stream");
|
||||
await db.contractLetter.create({
|
||||
data: { assignmentId: result.assignmentId, fileKey: key, salaryRestricted: formData.get("salaryRestricted") !== "false" },
|
||||
});
|
||||
}
|
||||
|
||||
revalidateApp(id, app.requisition.id);
|
||||
return { ok: true, id: result.employeeId };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,29 @@ describe("onboardCandidate", () => {
|
|||
|
||||
const action = await db.crewAction.findFirstOrThrow({ where: { actionType: "CREW_ONBOARDED" } });
|
||||
expect(action.actorId).toBe(managerId);
|
||||
// D3 AC2: the audit row records the created IDs in metadata.
|
||||
const meta = action.metadata as { assignmentId?: string; employeeId?: string; salaryStructureId?: string } | null;
|
||||
expect(meta?.assignmentId).toBe(assignment.id);
|
||||
expect(meta?.employeeId).toBe(cm.employeeId);
|
||||
expect(meta?.salaryStructureId).toBe(sal.id);
|
||||
});
|
||||
|
||||
it("blocks onboarding when no salary structure is Manager-approved (D1)", async () => {
|
||||
seq += 1;
|
||||
const req = await db.requisition.create({ data: { code: `REQ-O${seq}`, rankId, vesselId, reason: "NEW_VACANCY", status: "SELECTED" } });
|
||||
const cand = await db.crewMember.create({ data: { name: "Unapproved Sal", type: "NEW", status: "CANDIDATE", source: "CAREERS", appliedRankId: rankId } });
|
||||
const appRow = await db.application.create({ data: { requisitionId: req.id, crewMemberId: cand.id, stage: "SELECTED", type: "NEW" } });
|
||||
// Salary agreed but NOT Manager-approved (approvedById null).
|
||||
await db.salaryStructure.create({ data: { applicationId: appRow.id, rateBasis: "MONTHLY", basic: 40000 } });
|
||||
|
||||
as(managerId, "MANAGER");
|
||||
const res = await onboardCandidate(fd({ applicationId: appRow.id, joiningDate: "2026-07-01" }));
|
||||
expect("error" in res).toBe(true);
|
||||
expect(await db.crewAssignment.count()).toBe(0);
|
||||
// The candidate is untouched — still a CANDIDATE, no employee number.
|
||||
const after = await db.crewMember.findUniqueOrThrow({ where: { id: cand.id } });
|
||||
expect(after.status).toBe("CANDIDATE");
|
||||
expect(after.employeeId).toBeNull();
|
||||
});
|
||||
|
||||
it("requires a joining date", async () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue