feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -7,7 +7,6 @@ import {
|
||||
} from "@capakraken/application";
|
||||
import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, ResourceType, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@capakraken/shared";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { computeChargeability } from "@capakraken/engine";
|
||||
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
|
||||
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
|
||||
import {
|
||||
@@ -17,6 +16,12 @@ import {
|
||||
getAnonymizationDirectory,
|
||||
resolveResourceIdsByDisplayedEids,
|
||||
} from "../lib/anonymization.js";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveBookedHours,
|
||||
calculateEffectiveDayAvailability,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "../lib/resource-capacity.js";
|
||||
|
||||
export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool.
|
||||
|
||||
@@ -46,6 +51,50 @@ function parseResourceCursor(cursor: string | undefined): { displayName: string;
|
||||
return null;
|
||||
}
|
||||
|
||||
type BookingForCapacity = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
};
|
||||
|
||||
function toIsoDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function buildDailyBookedHoursMap(
|
||||
bookings: BookingForCapacity[],
|
||||
availability: WeekdayAvailability,
|
||||
context: Parameters<typeof calculateEffectiveBookedHours>[0]["context"],
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): Map<string, number> {
|
||||
const dailyBookedHours = new Map<string, number>();
|
||||
const cursor = new Date(periodStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(periodEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const isoDate = toIsoDate(cursor);
|
||||
const bookedHours = bookings.reduce(
|
||||
(sum, booking) => sum + calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart: cursor,
|
||||
periodEnd: cursor,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
dailyBookedHours.set(isoDate, bookedHours);
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return dailyBookedHours;
|
||||
}
|
||||
|
||||
export const resourceRouter = createTRPCRouter({
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
@@ -1056,10 +1105,14 @@ export const resourceRouter = createTRPCRouter({
|
||||
portfolioUrl: true,
|
||||
postalCode: true,
|
||||
federalState: true,
|
||||
countryId: true,
|
||||
metroCityId: true,
|
||||
valueScore: true,
|
||||
valueScoreBreakdown: true,
|
||||
valueScoreUpdatedAt: true,
|
||||
userId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
const bookings = await listAssignmentBookings(ctx.db, {
|
||||
@@ -1067,30 +1120,67 @@ export const resourceRouter = createTRPCRouter({
|
||||
endDate: end,
|
||||
resourceIds: resources.map((resource) => resource.id),
|
||||
});
|
||||
const bookingsByResourceId = new Map<string, typeof bookings>();
|
||||
for (const booking of bookings) {
|
||||
if (!booking.resourceId) {
|
||||
continue;
|
||||
}
|
||||
const items = bookingsByResourceId.get(booking.resourceId) ?? [];
|
||||
items.push(booking);
|
||||
bookingsByResourceId.set(booking.resourceId, items);
|
||||
}
|
||||
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,
|
||||
})),
|
||||
start,
|
||||
end,
|
||||
);
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
|
||||
return resources.map((r) => {
|
||||
const avail = r.availability as Record<string, number>;
|
||||
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
|
||||
const periodDays =
|
||||
(end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24) + 1;
|
||||
const availableHours = dailyAvailHours * periodDays * (5 / 7);
|
||||
|
||||
let bookedHours = 0;
|
||||
let isOverbooked = false;
|
||||
const resourceBookings = bookings.filter(
|
||||
const availability = r.availability as unknown as WeekdayAvailability;
|
||||
const context = contexts.get(r.id);
|
||||
const resourceBookings = (bookingsByResourceId.get(r.id) ?? []).filter(
|
||||
(booking) =>
|
||||
booking.resourceId === r.id &&
|
||||
(input.includeProposed || booking.status !== "PROPOSED"),
|
||||
);
|
||||
for (const a of resourceBookings) {
|
||||
const days =
|
||||
(new Date(a.endDate).getTime() - new Date(a.startDate).getTime()) /
|
||||
(1000 * 60 * 60 * 24) +
|
||||
1;
|
||||
bookedHours += a.hoursPerDay * days;
|
||||
if (a.hoursPerDay > dailyAvailHours) isOverbooked = true;
|
||||
}
|
||||
const availableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
});
|
||||
const bookedHours = resourceBookings.reduce(
|
||||
(sum, booking) => sum + calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const dailyBookedHours = buildDailyBookedHoursMap(resourceBookings, availability, context, start, end);
|
||||
const isOverbooked = Array.from(dailyBookedHours.entries()).some(([isoDate, hours]) => {
|
||||
const date = new Date(`${isoDate}T00:00:00.000Z`);
|
||||
const dayCapacity = calculateEffectiveDayAvailability({
|
||||
availability,
|
||||
date,
|
||||
context,
|
||||
});
|
||||
return dayCapacity > 0 && hours > dayCapacity;
|
||||
});
|
||||
|
||||
const utilizationPercent =
|
||||
availableHours > 0 ? Math.round((bookedHours / availableHours) * 100) : 0;
|
||||
@@ -1125,6 +1215,11 @@ export const resourceRouter = createTRPCRouter({
|
||||
chapter: true,
|
||||
chargeabilityTarget: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
const bookings = await listAssignmentBookings(ctx.db, {
|
||||
@@ -1132,10 +1227,25 @@ export const resourceRouter = createTRPCRouter({
|
||||
endDate: end,
|
||||
resourceIds: resources.map((resource) => resource.id),
|
||||
});
|
||||
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,
|
||||
})),
|
||||
start,
|
||||
end,
|
||||
);
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
|
||||
return resources.map((r) => {
|
||||
const avail = r.availability as unknown as WeekdayAvailability;
|
||||
const context = contexts.get(r.id);
|
||||
const resourceBookings = bookings.filter((booking) => booking.resourceId === r.id);
|
||||
|
||||
const actualAllocs = resourceBookings.filter((booking) =>
|
||||
@@ -1146,8 +1256,42 @@ export const resourceRouter = createTRPCRouter({
|
||||
isChargeabilityRelevantProject(booking.project, true),
|
||||
);
|
||||
|
||||
const actual = computeChargeability(avail, actualAllocs, start, end);
|
||||
const expected = computeChargeability(avail, expectedAllocs, start, end);
|
||||
const availableHours = calculateEffectiveAvailableHours({
|
||||
availability: avail,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
});
|
||||
const actualBookedHours = actualAllocs.reduce(
|
||||
(sum, booking) => sum + calculateEffectiveBookedHours({
|
||||
availability: avail,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const expectedBookedHours = expectedAllocs.reduce(
|
||||
(sum, booking) => sum + calculateEffectiveBookedHours({
|
||||
availability: avail,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const actualChargeability = availableHours > 0
|
||||
? Math.round((actualBookedHours / availableHours) * 100)
|
||||
: 0;
|
||||
const expectedChargeability = availableHours > 0
|
||||
? Math.round((expectedBookedHours / availableHours) * 100)
|
||||
: 0;
|
||||
|
||||
return anonymizeResource({
|
||||
id: r.id,
|
||||
@@ -1155,9 +1299,9 @@ export const resourceRouter = createTRPCRouter({
|
||||
displayName: r.displayName,
|
||||
chapter: r.chapter,
|
||||
chargeabilityTarget: r.chargeabilityTarget,
|
||||
actualChargeability: actual.chargeability,
|
||||
expectedChargeability: expected.chargeability,
|
||||
availableHours: actual.availableHours,
|
||||
actualChargeability,
|
||||
expectedChargeability,
|
||||
availableHours: Math.round(availableHours),
|
||||
}, directory);
|
||||
});
|
||||
}),
|
||||
@@ -1208,7 +1352,10 @@ export const resourceRouter = createTRPCRouter({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const now = new Date();
|
||||
const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
const today = new Date(now);
|
||||
today.setUTCHours(0, 0, 0, 0);
|
||||
const thirtyDaysFromNow = new Date(today);
|
||||
thirtyDaysFromNow.setUTCDate(thirtyDaysFromNow.getUTCDate() + 29);
|
||||
|
||||
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
|
||||
|
||||
@@ -1223,6 +1370,11 @@ export const resourceRouter = createTRPCRouter({
|
||||
skills: true,
|
||||
availability: true,
|
||||
chargeabilityTarget: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1232,7 +1384,7 @@ export const resourceRouter = createTRPCRouter({
|
||||
where: {
|
||||
resourceId: { in: allResourceIds },
|
||||
status: { in: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED"] },
|
||||
endDate: { gte: now },
|
||||
endDate: { gte: today },
|
||||
startDate: { lte: thirtyDaysFromNow },
|
||||
},
|
||||
select: {
|
||||
@@ -1242,41 +1394,78 @@ export const resourceRouter = createTRPCRouter({
|
||||
hoursPerDay: true,
|
||||
},
|
||||
});
|
||||
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,
|
||||
})),
|
||||
today,
|
||||
thirtyDaysFromNow,
|
||||
);
|
||||
const assignmentsByResourceId = new Map<string, typeof assignments>();
|
||||
for (const assignment of assignments) {
|
||||
const items = assignmentsByResourceId.get(assignment.resourceId) ?? [];
|
||||
items.push(assignment);
|
||||
assignmentsByResourceId.set(assignment.resourceId, items);
|
||||
}
|
||||
|
||||
// Build utilization map (simple: booked hours per day / available hours per day)
|
||||
// Build utilization map with holiday-aware daily capacity over the next 30 days.
|
||||
const utilizationMap = new Map<string, { utilizationPercent: number; earliestAvailableDate: Date | null }>();
|
||||
for (const r of resources) {
|
||||
const avail = r.availability as Record<string, number>;
|
||||
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
|
||||
const resourceAssignments = assignments.filter((a) => a.resourceId === r.id);
|
||||
const availability = r.availability as unknown as WeekdayAvailability;
|
||||
const context = contexts.get(r.id);
|
||||
const resourceAssignments = assignmentsByResourceId.get(r.id) ?? [];
|
||||
const todayAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: today,
|
||||
periodEnd: today,
|
||||
context,
|
||||
});
|
||||
const todayBookedHours = resourceAssignments.reduce(
|
||||
(sum, assignment) => sum + calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
periodStart: today,
|
||||
periodEnd: today,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const utilizationPercent = todayAvailableHours > 0
|
||||
? Math.round((todayBookedHours / todayAvailableHours) * 100)
|
||||
: 0;
|
||||
const dailyBookedHours = buildDailyBookedHoursMap(
|
||||
resourceAssignments,
|
||||
availability,
|
||||
context,
|
||||
today,
|
||||
thirtyDaysFromNow,
|
||||
);
|
||||
|
||||
// Current daily booked hours (assignments overlapping today)
|
||||
let todayBooked = 0;
|
||||
for (const a of resourceAssignments) {
|
||||
if (a.startDate <= now && a.endDate >= now) {
|
||||
todayBooked += a.hoursPerDay;
|
||||
}
|
||||
}
|
||||
const utilizationPercent = dailyAvailHours > 0 ? Math.round((todayBooked / dailyAvailHours) * 100) : 0;
|
||||
|
||||
// Find earliest date when resource has capacity (within 30 days)
|
||||
let earliestAvailableDate: Date | null = null;
|
||||
const checkDate = new Date(now);
|
||||
const checkDate = new Date(today);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const day = checkDate.getDay();
|
||||
if (day !== 0 && day !== 6) {
|
||||
let dayBooked = 0;
|
||||
for (const a of resourceAssignments) {
|
||||
if (a.startDate <= checkDate && a.endDate >= checkDate) {
|
||||
dayBooked += a.hoursPerDay;
|
||||
}
|
||||
}
|
||||
if (dayBooked < dailyAvailHours * 0.8) {
|
||||
const dayAvailableHours = calculateEffectiveDayAvailability({
|
||||
availability,
|
||||
date: checkDate,
|
||||
context,
|
||||
});
|
||||
if (dayAvailableHours > 0) {
|
||||
const dayBookedHours = dailyBookedHours.get(toIsoDate(checkDate)) ?? 0;
|
||||
if (dayBookedHours < dayAvailableHours * 0.8) {
|
||||
earliestAvailableDate = new Date(checkDate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
checkDate.setDate(checkDate.getDate() + 1);
|
||||
checkDate.setUTCDate(checkDate.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
utilizationMap.set(r.id, { utilizationPercent, earliestAvailableDate });
|
||||
|
||||
Reference in New Issue
Block a user