feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -1,8 +1,14 @@
|
||||
import { calculateAllocation, countWorkingDays } from "@capakraken/engine/allocation";
|
||||
import { calculateAllocation } from "@capakraken/engine/allocation";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createTRPCRouter, controllerProcedure, protectedProcedure } from "../trpc.js";
|
||||
import { createAuditEntry } from "../lib/audit.js";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveBookedHours,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "../lib/resource-capacity.js";
|
||||
|
||||
const DEFAULT_AVAILABILITY = {
|
||||
monday: 8,
|
||||
@@ -69,6 +75,11 @@ export const scenarioRouter = createTRPCRouter({
|
||||
availability: true,
|
||||
chargeabilityTarget: true,
|
||||
skills: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
roleEntity: { select: { id: true, name: true, color: true } },
|
||||
@@ -85,23 +96,53 @@ export const scenarioRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate baseline totals
|
||||
let totalCostCents = 0;
|
||||
let totalHours = 0;
|
||||
const assignmentRangeStart = assignments.length > 0
|
||||
? new Date(Math.min(...assignments.map((assignment) => assignment.startDate.getTime())))
|
||||
: project.startDate;
|
||||
const assignmentRangeEnd = assignments.length > 0
|
||||
? new Date(Math.max(...assignments.map((assignment) => assignment.endDate.getTime())))
|
||||
: project.endDate;
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.db,
|
||||
assignments
|
||||
.flatMap((assignment) => (assignment.resource ? [assignment.resource] : []))
|
||||
.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
assignmentRangeStart,
|
||||
assignmentRangeEnd,
|
||||
);
|
||||
|
||||
const baselineAllocations = assignments.map((a) => {
|
||||
const availability = (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
|
||||
const lcrCents = a.resource?.lcrCents ?? 0;
|
||||
const result = calculateAllocation({
|
||||
lcrCents,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
availability,
|
||||
});
|
||||
|
||||
totalCostCents += result.totalCostCents;
|
||||
totalHours += result.totalHours;
|
||||
const totalHours = a.resourceId
|
||||
? calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
periodStart: assignmentRangeStart,
|
||||
periodEnd: assignmentRangeEnd,
|
||||
context: contexts.get(a.resourceId),
|
||||
})
|
||||
: calculateAllocation({
|
||||
lcrCents,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
availability,
|
||||
}).totalHours;
|
||||
const costCents = Math.round(totalHours * lcrCents);
|
||||
const workingDays = a.hoursPerDay > 0
|
||||
? Math.round((totalHours / a.hoursPerDay) * 100) / 100
|
||||
: 0;
|
||||
|
||||
return {
|
||||
id: a.id,
|
||||
@@ -116,11 +157,13 @@ export const scenarioRouter = createTRPCRouter({
|
||||
endDate: a.endDate.toISOString(),
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
status: a.status,
|
||||
costCents: result.totalCostCents,
|
||||
totalHours: result.totalHours,
|
||||
workingDays: result.workingDays,
|
||||
costCents,
|
||||
totalHours,
|
||||
workingDays,
|
||||
};
|
||||
});
|
||||
const totalCostCents = baselineAllocations.reduce((sum, allocation) => sum + allocation.costCents, 0);
|
||||
const totalHours = baselineAllocations.reduce((sum, allocation) => sum + allocation.totalHours, 0);
|
||||
|
||||
const baselineDemands = demands.map((d) => ({
|
||||
id: d.id,
|
||||
@@ -175,27 +218,16 @@ export const scenarioRouter = createTRPCRouter({
|
||||
availability: true,
|
||||
chargeabilityTarget: true,
|
||||
skills: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Compute baseline totals
|
||||
let baselineCostCents = 0;
|
||||
let baselineHours = 0;
|
||||
for (const a of currentAssignments) {
|
||||
const availability = (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
|
||||
const result = calculateAllocation({
|
||||
lcrCents: a.resource?.lcrCents ?? 0,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
availability,
|
||||
});
|
||||
baselineCostCents += result.totalCostCents;
|
||||
baselineHours += result.totalHours;
|
||||
}
|
||||
|
||||
// Collect all resource IDs we need to look up (from changes)
|
||||
const resourceIds = new Set<string>();
|
||||
for (const c of changes) {
|
||||
@@ -217,6 +249,11 @@ export const scenarioRouter = createTRPCRouter({
|
||||
availability: true,
|
||||
chargeabilityTarget: true,
|
||||
skills: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
const resourceMap = new Map(resources.map((r) => [r.id, r]));
|
||||
@@ -287,22 +324,6 @@ export const scenarioRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Compute scenario totals
|
||||
let scenarioCostCents = 0;
|
||||
let scenarioHours = 0;
|
||||
|
||||
for (const entry of scenarioEntries) {
|
||||
const result = calculateAllocation({
|
||||
lcrCents: entry.lcrCents,
|
||||
hoursPerDay: entry.hoursPerDay,
|
||||
startDate: entry.startDate,
|
||||
endDate: entry.endDate,
|
||||
availability: entry.availability,
|
||||
});
|
||||
scenarioCostCents += result.totalCostCents;
|
||||
scenarioHours += result.totalHours;
|
||||
}
|
||||
|
||||
// Compute per-resource utilization impact
|
||||
// Load ALL assignments for affected resources (across all projects) to measure total utilization
|
||||
const affectedResourceIds = [...new Set(scenarioEntries.map((e) => e.resourceId).filter(Boolean))] as string[];
|
||||
@@ -341,13 +362,97 @@ export const scenarioRouter = createTRPCRouter({
|
||||
if (e.endDate > windowEnd) windowEnd = e.endDate;
|
||||
}
|
||||
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.db,
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
windowStart,
|
||||
windowEnd,
|
||||
);
|
||||
|
||||
function calculateEntryHours(entry: {
|
||||
resourceId: string | null;
|
||||
lcrCents: number;
|
||||
hoursPerDay: number;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
availability: typeof DEFAULT_AVAILABILITY;
|
||||
}) {
|
||||
if (!entry.resourceId) {
|
||||
return calculateAllocation({
|
||||
lcrCents: entry.lcrCents,
|
||||
hoursPerDay: entry.hoursPerDay,
|
||||
startDate: entry.startDate,
|
||||
endDate: entry.endDate,
|
||||
availability: entry.availability,
|
||||
}).totalHours;
|
||||
}
|
||||
|
||||
return calculateEffectiveBookedHours({
|
||||
availability: entry.availability,
|
||||
startDate: entry.startDate,
|
||||
endDate: entry.endDate,
|
||||
hoursPerDay: entry.hoursPerDay,
|
||||
periodStart: windowStart,
|
||||
periodEnd: windowEnd,
|
||||
context: contexts.get(entry.resourceId),
|
||||
});
|
||||
}
|
||||
|
||||
// Compute scenario totals
|
||||
let scenarioCostCents = 0;
|
||||
let scenarioHours = 0;
|
||||
|
||||
for (const entry of scenarioEntries) {
|
||||
const totalHours = calculateEntryHours(entry);
|
||||
scenarioCostCents += Math.round(totalHours * entry.lcrCents);
|
||||
scenarioHours += totalHours;
|
||||
}
|
||||
|
||||
let baselineCostCents = 0;
|
||||
let baselineHours = 0;
|
||||
for (const assignment of currentAssignments) {
|
||||
const availability = (assignment.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
|
||||
const totalHours = assignment.resourceId
|
||||
? calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
periodStart: windowStart,
|
||||
periodEnd: windowEnd,
|
||||
context: contexts.get(assignment.resourceId),
|
||||
})
|
||||
: calculateAllocation({
|
||||
lcrCents: assignment.resource?.lcrCents ?? 0,
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
availability,
|
||||
}).totalHours;
|
||||
baselineHours += totalHours;
|
||||
baselineCostCents += Math.round(totalHours * (assignment.resource?.lcrCents ?? 0));
|
||||
}
|
||||
|
||||
const resourceImpacts = affectedResourceIds.map((resId) => {
|
||||
const resource = resourceMap.get(resId);
|
||||
if (!resource) return null;
|
||||
|
||||
const availability = (resource.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
|
||||
const totalWorkDays = countWorkingDays(windowStart, windowEnd, availability);
|
||||
const totalAvailableHours = totalWorkDays * (availability.monday ?? 8);
|
||||
const context = contexts.get(resId);
|
||||
const totalAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: windowStart,
|
||||
periodEnd: windowEnd,
|
||||
context,
|
||||
});
|
||||
|
||||
// Current utilization on this project
|
||||
const currentProjectAssignments = (assignmentsByResource.get(resId) ?? []).filter(
|
||||
@@ -355,28 +460,30 @@ export const scenarioRouter = createTRPCRouter({
|
||||
);
|
||||
let currentProjectHours = 0;
|
||||
for (const a of currentProjectAssignments) {
|
||||
const r = calculateAllocation({
|
||||
lcrCents: 0,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
currentProjectHours += calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
availability,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
periodStart: windowStart,
|
||||
periodEnd: windowEnd,
|
||||
context,
|
||||
});
|
||||
currentProjectHours += r.totalHours;
|
||||
}
|
||||
|
||||
// Scenario hours for this resource on this project
|
||||
const scenarioResourceEntries = scenarioEntries.filter((e) => e.resourceId === resId);
|
||||
let scenarioProjectHours = 0;
|
||||
for (const e of scenarioResourceEntries) {
|
||||
const r = calculateAllocation({
|
||||
lcrCents: 0,
|
||||
hoursPerDay: e.hoursPerDay,
|
||||
scenarioProjectHours += calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: e.startDate,
|
||||
endDate: e.endDate,
|
||||
availability,
|
||||
hoursPerDay: e.hoursPerDay,
|
||||
periodStart: windowStart,
|
||||
periodEnd: windowEnd,
|
||||
context,
|
||||
});
|
||||
scenarioProjectHours += r.totalHours;
|
||||
}
|
||||
|
||||
// Total hours across all projects (excluding this project's current, adding scenario)
|
||||
@@ -385,14 +492,15 @@ export const scenarioRouter = createTRPCRouter({
|
||||
);
|
||||
let otherProjectsHours = 0;
|
||||
for (const a of otherProjectAssignments) {
|
||||
const r = calculateAllocation({
|
||||
lcrCents: 0,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
otherProjectsHours += calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
availability,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
periodStart: windowStart,
|
||||
periodEnd: windowEnd,
|
||||
context,
|
||||
});
|
||||
otherProjectsHours += r.totalHours;
|
||||
}
|
||||
|
||||
const currentTotalHours = otherProjectsHours + currentProjectHours;
|
||||
|
||||
Reference in New Issue
Block a user