pelagia-portal/App/lib/leave-clash.ts
Hardik 040a66488d
All checks were successful
PR checks / checks (pull_request) Successful in 38s
PR checks / integration (pull_request) Successful in 28s
feat(crewing): clash detection by required strength (Option A)
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>
2026-06-22 21:14:21 +05:30

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;
}