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>
This commit is contained in:
Hardik 2026-06-22 21:14:21 +05:30
parent aac31c6755
commit 040a66488d
6 changed files with 97 additions and 36 deletions

View file

@ -170,7 +170,7 @@ A crew-management module built incrementally per the **wiki `Crewing-Implementat
- **Models:** `LeaveRequest` (`LeaveType`, `LeaveStatus`) and `Attendance` (`AttendanceStatus`, `@@unique([assignmentId, date])`) hang off `CrewAssignment`. `CrewActionType += LEAVE_APPLIED / LEAVE_DECIDED / ATTENDANCE_RECORDED`.
- **Leave (R1):** **Site staff apply on behalf** (`apply_leave`); the **Manager decides** (`decide_leave`) — the **MPO has no leave role**. On approval the assignment goes `ON_LEAVE`. Leave approvals also surface in the central `/approvals` queue (§8.13 "Leave" kind, inline Approve/Decline). Notification `LEAVE_FOR_APPROVAL`.
- **Clash auto-backfill (R6):** `lib/leave-clash.ts` treats **required strength = 1** — approving a leave that would leave the vessel with **zero** active same-rank cover over the window auto-raises a `LEAVE` requisition via the Phase-2 `autoRaiseRequisition`. (Configurable per-vessel strength is a future refinement.)
- **Clash auto-backfill (R6, Option A):** `VesselRankRequirement{vesselId, rankId, minStrength}` configures required crew strength per rank per vessel. `lib/leave-clash.ts` flags a clash when approving a leave would drop the **active same-rank cover over the window below `minStrength`** (default **1** when unconfigured) → auto-raises a `LEAVE` requisition via the Phase-2 `autoRaiseRequisition`. The requirement is managed by the office (`manage_crew`).
- **Attendance (R5):** daily month calendar, **site staff record** (`record_attendance`), **Manager views** (`view_attendance`) but cannot edit, **MPO has neither**. `saveAttendance(assignmentId, marks)` bulk-upserts the dirty cells.
- **Screens:** `/crewing/leave` (apply-on-behalf modal + requests list with Manager Approve/Decline) and `/crewing/attendance` (crew dropdown + month grid, tap-to-cycle Present/Absent/Leave/Half-day, Save). **Leave** + **Attendance** added to the flag-gated nav (Manager + Site staff only).
- **Deferred:** the 6-month leave-planner timeline with clash bars (§8.9) is a lightweight list for now; hours/overtime attendance (A7) stays deferred.

View file

@ -4,7 +4,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission, type Permission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { leaveLeavesNoCover } from "@/lib/leave-clash";
import { leaveCausesClash } from "@/lib/leave-clash";
import { autoRaiseRequisition, getManagerRecipients } from "@/lib/requisition-service";
import { notifyCrew } from "@/lib/notifier";
import { LeaveType } from "@prisma/client";
@ -114,7 +114,7 @@ export async function decideLeave(id: string, approve: boolean, note?: string):
await tx.leaveRequest.update({ where: { id }, data: { status: "APPROVED", decidedById: g.userId, decidedAt: new Date() } });
await tx.crewAssignment.update({ where: { id: leave.assignment.id }, data: { status: "ON_LEAVE" } });
await tx.crewAction.create({ data: { actionType: "LEAVE_DECIDED", actorId: g.userId, crewMemberId: leave.assignment.crewMemberId, metadata: { decision: "APPROVED" } } });
const clash = await leaveLeavesNoCover(tx, {
const clash = await leaveCausesClash(tx, {
assignmentId: leave.assignment.id,
rankId: leave.assignment.rankId,
vesselId: leave.assignment.vesselId,

View file

@ -1,10 +1,10 @@
import type { Prisma } from "@prisma/client";
// Leave-clash detection (Crewing-Implementation-Spec §5.3, R6). Required strength
// is treated as 1: approving a leave is a clash when it would leave the vessel
// with ZERO active same-rank cover over the leave window — i.e. every other
// not-signed-off crew member of that rank on the vessel is either absent or on an
// approved leave that overlaps the window. A clash auto-raises a LEAVE requisition.
// 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;
@ -14,31 +14,41 @@ interface ClashInput {
toDate: Date;
}
export async function leaveLeavesNoCover(
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 },
});
// This crew member was the only same-rank cover on the vessel.
if (others.length === 0) return true;
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));
const remainingCover = otherIds.filter((id) => !out.has(id)).length;
return remainingCover === 0;
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;
}

View file

@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "VesselRankRequirement" (
"id" TEXT NOT NULL,
"vesselId" TEXT NOT NULL,
"rankId" TEXT NOT NULL,
"minStrength" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VesselRankRequirement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "VesselRankRequirement_vesselId_rankId_key" ON "VesselRankRequirement"("vesselId", "rankId");
-- AddForeignKey
ALTER TABLE "VesselRankRequirement" ADD CONSTRAINT "VesselRankRequirement_vesselId_fkey" FOREIGN KEY ("vesselId") REFERENCES "Vessel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VesselRankRequirement" ADD CONSTRAINT "VesselRankRequirement_rankId_fkey" FOREIGN KEY ("rankId") REFERENCES "Rank"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -338,10 +338,11 @@ model Vessel {
code String @unique
isActive Boolean @default(true)
purchaseOrders PurchaseOrder[]
requisitions Requisition[]
reliefRequests ReliefRequest[]
assignments CrewAssignment[]
purchaseOrders PurchaseOrder[]
requisitions Requisition[]
reliefRequests ReliefRequest[]
assignments CrewAssignment[]
rankRequirements VesselRankRequirement[]
}
model Company {
@ -626,13 +627,14 @@ model Rank {
parent Rank? @relation("RankHierarchy", fields: [parentId], references: [id])
children Rank[] @relation("RankHierarchy")
docRequirements RankDocRequirement[]
requisitions Requisition[]
reliefRequests ReliefRequest[]
crewCurrentRank CrewMember[] @relation("CrewCurrentRank")
crewAppliedRank CrewMember[] @relation("CrewAppliedRank")
assignments CrewAssignment[]
experienceRecords ExperienceRecord[]
docRequirements RankDocRequirement[]
requisitions Requisition[]
reliefRequests ReliefRequest[]
crewCurrentRank CrewMember[] @relation("CrewCurrentRank")
crewAppliedRank CrewMember[] @relation("CrewAppliedRank")
assignments CrewAssignment[]
experienceRecords ExperienceRecord[]
vesselRequirements VesselRankRequirement[]
}
// Which documents a rank is required (or conditionally required) to hold.
@ -950,6 +952,23 @@ model Attendance {
@@unique([assignmentId, date])
}
// Required crew strength per rank, per vessel (Phase 4b, Option A). Drives
// leave-clash detection (§5.3, R6): approving a leave is a clash when the active
// same-rank cover over the window would fall below this. Managed by the office
// (manage_crew). Absent a row, the clash check falls back to a strength of 1.
model VesselRankRequirement {
id String @id @default(cuid())
vesselId String
vessel Vessel @relation(fields: [vesselId], references: [id], onDelete: Cascade)
rankId String
rank Rank @relation(fields: [rankId], references: [id], onDelete: Cascade)
minStrength Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([vesselId, rankId])
}
// The signed contract for an assignment. `salaryRestricted` hides salary from
// site staff on the crew profile (Phase 4 display gating).
model ContractLetter {

View file

@ -54,6 +54,7 @@ afterEach(async () => {
await db.leaveRequest.deleteMany({});
await db.crewAssignment.deleteMany({});
await db.requisition.deleteMany({});
await db.vesselRankRequirement.deleteMany({});
await db.crewMember.deleteMany({});
vi.clearAllMocks();
});
@ -107,7 +108,7 @@ describe("clash auto-backfill (required strength = 1)", () => {
expect(req!.vesselId).toBe(vesselId);
});
it("does NOT auto-raise when another active same-rank crew remains", async () => {
it("does NOT auto-raise when another active same-rank crew remains (default strength 1)", async () => {
const a = await makeAssignment("Going On Leave");
await makeAssignment("Stays Active"); // same rank + vessel, active
const leaveId = await applyAndGetId(a.id);
@ -115,6 +116,17 @@ describe("clash auto-backfill (required strength = 1)", () => {
await decideLeave(leaveId, true);
expect(await db.requisition.count({ where: { autoRaised: true } })).toBe(0);
});
it("auto-raises when a configured required strength exceeds the remaining cover (Option A)", async () => {
// Require 2 of this rank on the vessel; with one remaining after leave → clash.
await db.vesselRankRequirement.create({ data: { vesselId, rankId, minStrength: 2 } });
const a = await makeAssignment("Going On Leave");
await makeAssignment("Stays Active");
const leaveId = await applyAndGetId(a.id);
as(managerId, "MANAGER");
await decideLeave(leaveId, true);
expect(await db.requisition.count({ where: { autoRaised: true } })).toBe(1);
});
});
describe("attendance", () => {