Replace the implicit "strength = 1" clash rule with a configurable per-vessel,
per-rank requirement (director decision). Adds VesselRankRequirement
{vesselId, rankId, minStrength} (migration crewing_vessel_rank_requirement) and
reworks lib/leave-clash.ts → leaveCausesClash: a leave approval clashes when the
remaining active same-rank cover over the window would fall below minStrength
(default 1 when unconfigured), auto-raising a LEAVE requisition. The requirement
is managed by the office (manage_crew, admin UI in the follow-up).
- Integration: leave-attendance.test.ts gains a configured-strength case
(minStrength 2, one remaining → clash). Full unit (240) + integration (183) green.
- CLAUDE.md updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
54 lines
1.8 KiB
TypeScript
54 lines
1.8 KiB
TypeScript
import type { Prisma } from "@prisma/client";
|
|
|
|
// Leave-clash detection (Crewing-Implementation-Spec §5.3, R6 — Option A).
|
|
// Approving a leave is a clash when the remaining ACTIVE same-rank cover on the
|
|
// vessel over the leave window would fall BELOW the rank's required strength for
|
|
// that vessel (VesselRankRequirement.minStrength, default 1 when unconfigured).
|
|
// A clash auto-raises a LEAVE requisition.
|
|
|
|
interface ClashInput {
|
|
assignmentId: string;
|
|
rankId: string;
|
|
vesselId: string | null;
|
|
fromDate: Date;
|
|
toDate: Date;
|
|
}
|
|
|
|
export async function leaveCausesClash(
|
|
tx: Prisma.TransactionClient,
|
|
{ assignmentId, rankId, vesselId, fromDate, toDate }: ClashInput
|
|
): Promise<boolean> {
|
|
// No vessel cost axis → no rank-cover check.
|
|
if (!vesselId) return false;
|
|
|
|
const requirement = await tx.vesselRankRequirement.findUnique({
|
|
where: { vesselId_rankId: { vesselId, rankId } },
|
|
select: { minStrength: true },
|
|
});
|
|
const requiredStrength = requirement?.minStrength ?? 1;
|
|
if (requiredStrength <= 0) return false;
|
|
|
|
// Other not-signed-off same-rank crew on the vessel (excludes the one going on leave).
|
|
const others = await tx.crewAssignment.findMany({
|
|
where: { rankId, vesselId, status: { not: "SIGNED_OFF" }, id: { not: assignmentId } },
|
|
select: { id: true },
|
|
});
|
|
|
|
let remainingCover = 0;
|
|
if (others.length > 0) {
|
|
const otherIds = others.map((o) => o.id);
|
|
const overlapping = await tx.leaveRequest.findMany({
|
|
where: {
|
|
assignmentId: { in: otherIds },
|
|
status: "APPROVED",
|
|
fromDate: { lte: toDate },
|
|
toDate: { gte: fromDate },
|
|
},
|
|
select: { assignmentId: true },
|
|
});
|
|
const out = new Set(overlapping.map((l) => l.assignmentId));
|
|
remainingCover = otherIds.filter((id) => !out.has(id)).length;
|
|
}
|
|
|
|
return remainingCover < requiredStrength;
|
|
}
|