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:
2026-03-23 08:51:49 +01:00
parent 1f079d0309
commit 47b2aeec72
8 changed files with 408 additions and 189 deletions
+21 -1
View File
@@ -4,7 +4,7 @@
*/
import { prisma } from "@planarchy/db";
import { calculateAllocation, countWorkingDays } from "@planarchy/engine/allocation";
import { calculateAllocation, checkDuplicateAssignment, countWorkingDays } from "@planarchy/engine/allocation";
import { computeBudgetStatus } from "@planarchy/engine";
import type { PermissionKey } from "@planarchy/shared";
import { parseTaskAction } from "@planarchy/shared";
@@ -2195,6 +2195,16 @@ const executors = {
const startDate = new Date(params.startDate);
const endDate = new Date(params.endDate);
// Check for overlapping duplicate assignments (same resource + project + overlapping dates)
const existingAssignments = await ctx.db.assignment.findMany({
where: { resourceId: resource.id, status: { not: "CANCELLED" } },
select: { id: true, resourceId: true, projectId: true, startDate: true, endDate: true, status: true },
});
const dupCheck = checkDuplicateAssignment(resource.id, project.id, startDate, endDate, existingAssignments);
if (dupCheck.isDuplicate) {
return { error: dupCheck.message + " Use update_allocation_status to modify the existing assignment." };
}
// Check for existing CANCELLED allocation with same unique key — reactivate it
const existing = await ctx.db.assignment.findUnique({
where: {
@@ -3002,6 +3012,16 @@ const executors = {
}
if (!resource) return { error: `Resource not found: ${params.resourceId}` };
// Check for overlapping duplicate assignments (same resource + project + overlapping dates)
const existingAssignments = await ctx.db.assignment.findMany({
where: { resourceId: resource.id, status: { not: "CANCELLED" } },
select: { id: true, resourceId: true, projectId: true, startDate: true, endDate: true, status: true },
});
const dupCheck = checkDuplicateAssignment(resource.id, demand.project.id, demand.startDate, demand.endDate, existingAssignments);
if (dupCheck.isDuplicate) {
return { error: dupCheck.message + " Use update_allocation_status to modify the existing assignment." };
}
const roleName = demand.roleEntity?.name ?? demand.role ?? null;
const dailyCostCents = Math.round(resource.lcrCents * demand.hoursPerDay);
const assignment = await ctx.db.assignment.create({