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:
parent
aac31c6755
commit
040a66488d
6 changed files with 97 additions and 36 deletions
|
|
@ -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`.
|
- **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`.
|
- **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.
|
- **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).
|
- **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.
|
- **Deferred:** the 6-month leave-planner timeline with clash bars (§8.9) is a lightweight list for now; hours/overtime attendance (A7) stays deferred.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { hasPermission, type Permission } from "@/lib/permissions";
|
import { hasPermission, type Permission } from "@/lib/permissions";
|
||||||
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
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 { autoRaiseRequisition, getManagerRecipients } from "@/lib/requisition-service";
|
||||||
import { notifyCrew } from "@/lib/notifier";
|
import { notifyCrew } from "@/lib/notifier";
|
||||||
import { LeaveType } from "@prisma/client";
|
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.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.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" } } });
|
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,
|
assignmentId: leave.assignment.id,
|
||||||
rankId: leave.assignment.rankId,
|
rankId: leave.assignment.rankId,
|
||||||
vesselId: leave.assignment.vesselId,
|
vesselId: leave.assignment.vesselId,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
// Leave-clash detection (Crewing-Implementation-Spec §5.3, R6). Required strength
|
// Leave-clash detection (Crewing-Implementation-Spec §5.3, R6 — Option A).
|
||||||
// is treated as 1: approving a leave is a clash when it would leave the vessel
|
// Approving a leave is a clash when the remaining ACTIVE same-rank cover on the
|
||||||
// with ZERO active same-rank cover over the leave window — i.e. every other
|
// vessel over the leave window would fall BELOW the rank's required strength for
|
||||||
// not-signed-off crew member of that rank on the vessel is either absent or on an
|
// that vessel (VesselRankRequirement.minStrength, default 1 when unconfigured).
|
||||||
// approved leave that overlaps the window. A clash auto-raises a LEAVE requisition.
|
// A clash auto-raises a LEAVE requisition.
|
||||||
|
|
||||||
interface ClashInput {
|
interface ClashInput {
|
||||||
assignmentId: string;
|
assignmentId: string;
|
||||||
|
|
@ -14,31 +14,41 @@ interface ClashInput {
|
||||||
toDate: Date;
|
toDate: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function leaveLeavesNoCover(
|
export async function leaveCausesClash(
|
||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
{ assignmentId, rankId, vesselId, fromDate, toDate }: ClashInput
|
{ assignmentId, rankId, vesselId, fromDate, toDate }: ClashInput
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
// No vessel cost axis → no rank-cover check.
|
// No vessel cost axis → no rank-cover check.
|
||||||
if (!vesselId) return false;
|
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({
|
const others = await tx.crewAssignment.findMany({
|
||||||
where: { rankId, vesselId, status: { not: "SIGNED_OFF" }, id: { not: assignmentId } },
|
where: { rankId, vesselId, status: { not: "SIGNED_OFF" }, id: { not: assignmentId } },
|
||||||
select: { id: true },
|
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);
|
let remainingCover = 0;
|
||||||
const overlapping = await tx.leaveRequest.findMany({
|
if (others.length > 0) {
|
||||||
where: {
|
const otherIds = others.map((o) => o.id);
|
||||||
assignmentId: { in: otherIds },
|
const overlapping = await tx.leaveRequest.findMany({
|
||||||
status: "APPROVED",
|
where: {
|
||||||
fromDate: { lte: toDate },
|
assignmentId: { in: otherIds },
|
||||||
toDate: { gte: fromDate },
|
status: "APPROVED",
|
||||||
},
|
fromDate: { lte: toDate },
|
||||||
select: { assignmentId: true },
|
toDate: { gte: fromDate },
|
||||||
});
|
},
|
||||||
const out = new Set(overlapping.map((l) => l.assignmentId));
|
select: { assignmentId: true },
|
||||||
const remainingCover = otherIds.filter((id) => !out.has(id)).length;
|
});
|
||||||
return remainingCover === 0;
|
const out = new Set(overlapping.map((l) => l.assignmentId));
|
||||||
|
remainingCover = otherIds.filter((id) => !out.has(id)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return remainingCover < requiredStrength;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -338,10 +338,11 @@ model Vessel {
|
||||||
code String @unique
|
code String @unique
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
purchaseOrders PurchaseOrder[]
|
purchaseOrders PurchaseOrder[]
|
||||||
requisitions Requisition[]
|
requisitions Requisition[]
|
||||||
reliefRequests ReliefRequest[]
|
reliefRequests ReliefRequest[]
|
||||||
assignments CrewAssignment[]
|
assignments CrewAssignment[]
|
||||||
|
rankRequirements VesselRankRequirement[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Company {
|
model Company {
|
||||||
|
|
@ -626,13 +627,14 @@ model Rank {
|
||||||
parent Rank? @relation("RankHierarchy", fields: [parentId], references: [id])
|
parent Rank? @relation("RankHierarchy", fields: [parentId], references: [id])
|
||||||
children Rank[] @relation("RankHierarchy")
|
children Rank[] @relation("RankHierarchy")
|
||||||
|
|
||||||
docRequirements RankDocRequirement[]
|
docRequirements RankDocRequirement[]
|
||||||
requisitions Requisition[]
|
requisitions Requisition[]
|
||||||
reliefRequests ReliefRequest[]
|
reliefRequests ReliefRequest[]
|
||||||
crewCurrentRank CrewMember[] @relation("CrewCurrentRank")
|
crewCurrentRank CrewMember[] @relation("CrewCurrentRank")
|
||||||
crewAppliedRank CrewMember[] @relation("CrewAppliedRank")
|
crewAppliedRank CrewMember[] @relation("CrewAppliedRank")
|
||||||
assignments CrewAssignment[]
|
assignments CrewAssignment[]
|
||||||
experienceRecords ExperienceRecord[]
|
experienceRecords ExperienceRecord[]
|
||||||
|
vesselRequirements VesselRankRequirement[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Which documents a rank is required (or conditionally required) to hold.
|
// Which documents a rank is required (or conditionally required) to hold.
|
||||||
|
|
@ -950,6 +952,23 @@ model Attendance {
|
||||||
@@unique([assignmentId, date])
|
@@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
|
// The signed contract for an assignment. `salaryRestricted` hides salary from
|
||||||
// site staff on the crew profile (Phase 4 display gating).
|
// site staff on the crew profile (Phase 4 display gating).
|
||||||
model ContractLetter {
|
model ContractLetter {
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ afterEach(async () => {
|
||||||
await db.leaveRequest.deleteMany({});
|
await db.leaveRequest.deleteMany({});
|
||||||
await db.crewAssignment.deleteMany({});
|
await db.crewAssignment.deleteMany({});
|
||||||
await db.requisition.deleteMany({});
|
await db.requisition.deleteMany({});
|
||||||
|
await db.vesselRankRequirement.deleteMany({});
|
||||||
await db.crewMember.deleteMany({});
|
await db.crewMember.deleteMany({});
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
@ -107,7 +108,7 @@ describe("clash auto-backfill (required strength = 1)", () => {
|
||||||
expect(req!.vesselId).toBe(vesselId);
|
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");
|
const a = await makeAssignment("Going On Leave");
|
||||||
await makeAssignment("Stays Active"); // same rank + vessel, active
|
await makeAssignment("Stays Active"); // same rank + vessel, active
|
||||||
const leaveId = await applyAndGetId(a.id);
|
const leaveId = await applyAndGetId(a.id);
|
||||||
|
|
@ -115,6 +116,17 @@ describe("clash auto-backfill (required strength = 1)", () => {
|
||||||
await decideLeave(leaveId, true);
|
await decideLeave(leaveId, true);
|
||||||
expect(await db.requisition.count({ where: { autoRaised: true } })).toBe(0);
|
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", () => {
|
describe("attendance", () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue