feat: prevent duplicate resource-project assignments
Engine (packages/engine): - New checkDuplicateAssignment() pure function: detects same resource assigned to same project with overlapping dates - 15 unit tests covering: overlap, no-overlap, cancelled, self-exclude, string dates, PROPOSED status Application layer (packages/application): - createAssignment: throws CONFLICT before DB write if duplicate found - fillDemandRequirement: same check before entering transaction AI Assistant (packages/api/router/assistant-tools.ts): - create_allocation: checks before creating, returns helpful error message - fill_demand: same check using demand's projectId UI (apps/web): - AllocationModal: amber warning when resource already assigned to selected project with overlapping dates (non-blocking) Database cleanup: - Found and merged 1 duplicate: Wong Wong on Porsche Taycan Sport Film (2 overlapping PROPOSED assignments merged into 1) Regression: 298 engine tests pass (283 + 15 new). TypeScript clean. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { checkDuplicateAssignment, type ExistingAssignment } from "../allocation/duplicate-check.js";
|
||||
|
||||
const base: ExistingAssignment = {
|
||||
id: "a1",
|
||||
resourceId: "r1",
|
||||
projectId: "p1",
|
||||
startDate: new Date("2026-03-01"),
|
||||
endDate: new Date("2026-06-30"),
|
||||
status: "CONFIRMED",
|
||||
};
|
||||
|
||||
describe("checkDuplicateAssignment", () => {
|
||||
it("returns no duplicate when no existing assignments", () => {
|
||||
const result = checkDuplicateAssignment("r1", "p1", "2026-04-01", "2026-05-01", []);
|
||||
expect(result.isDuplicate).toBe(false);
|
||||
});
|
||||
|
||||
it("returns no duplicate for different project", () => {
|
||||
const result = checkDuplicateAssignment("r1", "p2", "2026-04-01", "2026-05-01", [base]);
|
||||
expect(result.isDuplicate).toBe(false);
|
||||
});
|
||||
|
||||
it("returns no duplicate for different resource", () => {
|
||||
const result = checkDuplicateAssignment("r2", "p1", "2026-04-01", "2026-05-01", [base]);
|
||||
expect(result.isDuplicate).toBe(false);
|
||||
});
|
||||
|
||||
it("returns no duplicate when dates don't overlap (before)", () => {
|
||||
const result = checkDuplicateAssignment("r1", "p1", "2026-01-01", "2026-02-28", [base]);
|
||||
expect(result.isDuplicate).toBe(false);
|
||||
});
|
||||
|
||||
it("returns no duplicate when dates don't overlap (after)", () => {
|
||||
const result = checkDuplicateAssignment("r1", "p1", "2026-07-01", "2026-08-01", [base]);
|
||||
expect(result.isDuplicate).toBe(false);
|
||||
});
|
||||
|
||||
it("detects duplicate with full overlap", () => {
|
||||
const result = checkDuplicateAssignment("r1", "p1", "2026-03-15", "2026-05-15", [base]);
|
||||
expect(result.isDuplicate).toBe(true);
|
||||
expect(result.conflictingAssignment?.id).toBe("a1");
|
||||
expect(result.message).toContain("already assigned");
|
||||
});
|
||||
|
||||
it("detects duplicate with partial overlap (start before, end during)", () => {
|
||||
const result = checkDuplicateAssignment("r1", "p1", "2026-02-15", "2026-04-01", [base]);
|
||||
expect(result.isDuplicate).toBe(true);
|
||||
});
|
||||
|
||||
it("detects duplicate with partial overlap (start during, end after)", () => {
|
||||
const result = checkDuplicateAssignment("r1", "p1", "2026-05-01", "2026-07-15", [base]);
|
||||
expect(result.isDuplicate).toBe(true);
|
||||
});
|
||||
|
||||
it("detects duplicate on exact same dates", () => {
|
||||
const result = checkDuplicateAssignment("r1", "p1", "2026-03-01", "2026-06-30", [base]);
|
||||
expect(result.isDuplicate).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores CANCELLED assignments", () => {
|
||||
const cancelled = { ...base, status: "CANCELLED" };
|
||||
const result = checkDuplicateAssignment("r1", "p1", "2026-04-01", "2026-05-01", [cancelled]);
|
||||
expect(result.isDuplicate).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores DRAFT assignments", () => {
|
||||
const draft = { ...base, status: "DRAFT" };
|
||||
const result = checkDuplicateAssignment("r1", "p1", "2026-04-01", "2026-05-01", [draft]);
|
||||
expect(result.isDuplicate).toBe(false);
|
||||
});
|
||||
|
||||
it("excludes self by ID (for updates)", () => {
|
||||
const result = checkDuplicateAssignment("r1", "p1", "2026-04-01", "2026-05-01", [base], "a1");
|
||||
expect(result.isDuplicate).toBe(false);
|
||||
});
|
||||
|
||||
it("does not exclude other assignments when excludeId is set", () => {
|
||||
const other: ExistingAssignment = { ...base, id: "a2" };
|
||||
const result = checkDuplicateAssignment("r1", "p1", "2026-04-01", "2026-05-01", [other], "a1");
|
||||
expect(result.isDuplicate).toBe(true);
|
||||
});
|
||||
|
||||
it("works with string dates", () => {
|
||||
const strAssignment: ExistingAssignment = {
|
||||
...base,
|
||||
startDate: "2026-03-01",
|
||||
endDate: "2026-06-30",
|
||||
};
|
||||
const result = checkDuplicateAssignment("r1", "p1", "2026-04-01", "2026-05-01", [strAssignment]);
|
||||
expect(result.isDuplicate).toBe(true);
|
||||
});
|
||||
|
||||
it("checks PROPOSED status as active", () => {
|
||||
const proposed = { ...base, status: "PROPOSED" };
|
||||
const result = checkDuplicateAssignment("r1", "p1", "2026-04-01", "2026-05-01", [proposed]);
|
||||
expect(result.isDuplicate).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user