feat(crewing): enforce recruitment vetting gates C5 + partial C3
- C5 (Epic C5 AC1): advanceStage("verify_competency") now requires ≥1
ReferenceCheck before leaving COMPETENCY_AND_REFERENCES.
- C3 (Epic C3 AC1): verifyDocuments blocks advancement when a mandatory document
for the seat's rank that the candidate holds is expired. Missing-document
presence stays enforced post-onboarding in the verification queue (seafarer
docs aren't collected pre-onboarding) — documented inline + in wiki Tech-Debt.
- C4 (experience): deferred with an inline note (Requisition has no
min-experience field yet — Epic A2 AC1).
applications.test.ts: reference-gate block/pass and expired-required-doc
block/renew-pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d796e81efc
commit
184250f903
2 changed files with 74 additions and 0 deletions
|
|
@ -115,6 +115,16 @@ export async function advanceStage(id: string, action: ApplicationAction): Promi
|
|||
if (!transition) return { error: `Cannot ${action} from ${app.stage}` };
|
||||
if (!canPerformAction(app.stage, action, g.role)) return { error: "Unauthorized" };
|
||||
|
||||
// C5 (spec §5.1 / Epic C5 AC1): at least one reference must be recorded before
|
||||
// leaving the COMPETENCY_AND_REFERENCES stage. The merged competency+references
|
||||
// gate is completed by `verify_competency`.
|
||||
if (action === "verify_competency") {
|
||||
const references = await db.referenceCheck.count({ where: { applicationId: id } });
|
||||
if (references === 0) {
|
||||
return { error: "Record at least one reference check before completing competency & references" };
|
||||
}
|
||||
}
|
||||
|
||||
await db.application.update({
|
||||
where: { id },
|
||||
data: {
|
||||
|
|
@ -207,6 +217,33 @@ export async function verifyDocuments(formData: FormData): Promise<ActionResult>
|
|||
const d = parsed.data;
|
||||
const crewMemberId = app.crewMember.id;
|
||||
|
||||
// C3 (spec §5.1 / Epic C3 AC1): block advancement when a mandatory document for
|
||||
// the seat's rank is EXPIRED.
|
||||
// Scope note (documented limitation): seafarer documents are collected on the
|
||||
// crew profile *after* onboarding (Phase 4a) — during the pipeline a candidate
|
||||
// usually has none on file, so a hard "missing document" block would stall the
|
||||
// whole funnel. We therefore gate on what is available (expiry of documents the
|
||||
// candidate already holds); the "all required documents present" check is
|
||||
// enforced post-onboarding in the verification queue (§8.11). Once careers
|
||||
// intake (A2) uploads documents pre-onboarding, tighten this to also require
|
||||
// presence of every mandatory docType.
|
||||
const reqRank = await db.requisition.findUnique({ where: { id: app.requisition.id }, select: { rankId: true } });
|
||||
if (reqRank) {
|
||||
const [required, candidateDocs] = await Promise.all([
|
||||
db.rankDocRequirement.findMany({ where: { rankId: reqRank.rankId, isMandatory: true }, select: { docType: true } }),
|
||||
db.seafarerDocument.findMany({ where: { crewMemberId }, select: { docType: true, expiryDate: true } }),
|
||||
]);
|
||||
const requiredTypes = new Set(required.map((r) => r.docType));
|
||||
const now = new Date();
|
||||
const expired = candidateDocs.filter((doc) => requiredTypes.has(doc.docType) && doc.expiryDate && doc.expiryDate < now);
|
||||
if (expired.length > 0) {
|
||||
return { error: `Cannot verify documents — a required document is expired: ${expired.map((doc) => doc.docType).join(", ")}` };
|
||||
}
|
||||
}
|
||||
// C4 (experience check) is deferred: the Requisition has no min-experience
|
||||
// criteria field yet (see Epic A2 AC1 / wiki Tech-Debt). Once that lands, compare
|
||||
// the candidate's ExperienceRecord total against it here and flag a shortfall.
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
// Capture bank / EPF (PII — encryption deferred to Phase 4).
|
||||
await tx.bankDetail.upsert({
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ afterEach(async () => {
|
|||
await db.salaryStructure.deleteMany({});
|
||||
await db.applicationGate.deleteMany({});
|
||||
await db.referenceCheck.deleteMany({});
|
||||
await db.seafarerDocument.deleteMany({});
|
||||
await db.application.deleteMany({});
|
||||
await db.bankDetail.deleteMany({});
|
||||
await db.epfDetail.deleteMany({});
|
||||
|
|
@ -191,6 +192,42 @@ describe("interview waiver (ex-hands, R2)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("vetting gates (C3/C5)", () => {
|
||||
it("blocks completing competency & references until a reference is recorded (C5)", async () => {
|
||||
const { applicationId } = await newApplication();
|
||||
as(manningId, "MANNING");
|
||||
await advanceStage(applicationId, "start_competency"); // → COMPETENCY_AND_REFERENCES
|
||||
// No reference recorded yet → cannot advance.
|
||||
expect("error" in (await advanceStage(applicationId, "verify_competency"))).toBe(true);
|
||||
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("COMPETENCY_AND_REFERENCES");
|
||||
// Record one → now it advances.
|
||||
await recordReferenceCheck(fd({ applicationId, refereeName: "Capt. Rao", outcome: "positive" }));
|
||||
expect("ok" in (await advanceStage(applicationId, "verify_competency"))).toBe(true);
|
||||
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("DOC_VERIFICATION");
|
||||
});
|
||||
|
||||
it("blocks document verification when a required document on file is expired (C3)", async () => {
|
||||
const { applicationId, requisitionId, crewMemberId } = await newApplication();
|
||||
await setStage(applicationId, "DOC_VERIFICATION");
|
||||
const reqRank = (await db.requisition.findUniqueOrThrow({ where: { id: requisitionId } })).rankId;
|
||||
await db.rankDocRequirement.upsert({
|
||||
where: { rankId_docType: { rankId: reqRank, docType: "MEDICAL_FITNESS" } },
|
||||
update: { isMandatory: true },
|
||||
create: { rankId: reqRank, docType: "MEDICAL_FITNESS", isMandatory: true },
|
||||
});
|
||||
await db.seafarerDocument.create({ data: { crewMemberId, docType: "MEDICAL_FITNESS", expiryDate: new Date("2020-01-01") } });
|
||||
|
||||
as(manningId, "MANNING");
|
||||
expect("error" in (await verifyDocuments(fd({ applicationId })))).toBe(true);
|
||||
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("DOC_VERIFICATION");
|
||||
|
||||
// Renew the document → advancement proceeds.
|
||||
await db.seafarerDocument.updateMany({ where: { crewMemberId }, data: { expiryDate: new Date("2030-01-01") } });
|
||||
expect("ok" in (await verifyDocuments(fd({ applicationId })))).toBe(true);
|
||||
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("SALARY_AGREEMENT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rejection", () => {
|
||||
it("MPO rejects from a mid stage", async () => {
|
||||
const { applicationId } = await newApplication();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue