feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -5,19 +5,18 @@ import {
|
||||
sumFte,
|
||||
getMonthRange,
|
||||
getMonthKeys,
|
||||
countWorkingDaysInOverlap,
|
||||
calculateSAH,
|
||||
calculateAllocation,
|
||||
DEFAULT_CALCULATION_RULES,
|
||||
type AssignmentSlice,
|
||||
} from "@capakraken/engine";
|
||||
import type { CalculationRule, AbsenceDay } from "@capakraken/shared";
|
||||
import type { SpainScheduleRule } from "@capakraken/shared";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
|
||||
import { VacationStatus } from "@capakraken/db";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
||||
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveBookedHours,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "../lib/resource-capacity.js";
|
||||
|
||||
export const chargeabilityReportRouter = createTRPCRouter({
|
||||
getReport: controllerProcedure
|
||||
@@ -59,6 +58,10 @@ export const chargeabilityReportRouter = createTRPCRouter({
|
||||
eid: true,
|
||||
displayName: true,
|
||||
fte: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
chargeabilityTarget: true,
|
||||
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
|
||||
orgUnit: { select: { id: true, name: true } },
|
||||
@@ -90,6 +93,20 @@ export const chargeabilityReportRouter = createTRPCRouter({
|
||||
endDate: rangeEnd,
|
||||
resourceIds,
|
||||
});
|
||||
const availabilityContexts = 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,
|
||||
})),
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
);
|
||||
|
||||
// Enrich with utilization category — fetch project util categories in bulk
|
||||
const projectIds = [...new Set(allBookings.map((b) => b.projectId))];
|
||||
@@ -118,152 +135,59 @@ export const chargeabilityReportRouter = createTRPCRouter({
|
||||
},
|
||||
}));
|
||||
|
||||
// Fetch vacations/absences in the range (including type for rules engine)
|
||||
const vacations = await ctx.db.vacation.findMany({
|
||||
where: {
|
||||
resourceId: { in: resourceIds },
|
||||
status: VacationStatus.APPROVED,
|
||||
startDate: { lte: rangeEnd },
|
||||
endDate: { gte: rangeStart },
|
||||
},
|
||||
select: {
|
||||
resourceId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
type: true,
|
||||
isHalfDay: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Load calculation rules for chargeability adjustments
|
||||
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
|
||||
try {
|
||||
const dbRules = await ctx.db.calculationRule.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: [{ priority: "desc" }],
|
||||
});
|
||||
if (dbRules.length > 0) {
|
||||
calcRules = dbRules as unknown as CalculationRule[];
|
||||
}
|
||||
} catch {
|
||||
// table may not exist yet
|
||||
}
|
||||
|
||||
// Build per-resource, per-month forecasts
|
||||
const resourceRows = resources.map((resource) => {
|
||||
const resourceRows = await Promise.all(resources.map(async (resource) => {
|
||||
const resourceAssignments = assignments.filter((a) => a.resourceId === resource.id);
|
||||
const resourceVacations = vacations.filter((v) => v.resourceId === resource.id);
|
||||
// Prefer mgmt level group target; fall back to legacy chargeabilityTarget (0-100 → 0-1)
|
||||
const targetPct = resource.managementLevelGroup?.targetPercentage
|
||||
?? (resource.chargeabilityTarget / 100);
|
||||
const dailyHours = resource.country?.dailyWorkingHours ?? 8;
|
||||
const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null;
|
||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||
const context = availabilityContexts.get(resource.id);
|
||||
|
||||
const months = monthKeys.map((key) => {
|
||||
const months = await Promise.all(monthKeys.map(async (key) => {
|
||||
const [y, m] = key.split("-").map(Number) as [number, number];
|
||||
const { start: monthStart, end: monthEnd } = getMonthRange(y, m);
|
||||
|
||||
// Compute absence days for SAH
|
||||
const absenceDates: string[] = [];
|
||||
for (const v of resourceVacations) {
|
||||
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
|
||||
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
|
||||
if (vStart > vEnd) continue;
|
||||
const cursor = new Date(vStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const endNorm = new Date(vEnd);
|
||||
endNorm.setUTCHours(0, 0, 0, 0);
|
||||
while (cursor <= endNorm) {
|
||||
absenceDates.push(cursor.toISOString().slice(0, 10));
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate SAH for this resource+month
|
||||
const sahResult = calculateSAH({
|
||||
dailyWorkingHours: dailyHours,
|
||||
scheduleRules,
|
||||
fte: resource.fte,
|
||||
const availableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
publicHolidays: [], // TODO: integrate public holidays from country
|
||||
absenceDays: absenceDates,
|
||||
context,
|
||||
});
|
||||
|
||||
// Build typed absence days for this resource in this month
|
||||
const monthAbsenceDays: AbsenceDay[] = [];
|
||||
for (const v of resourceVacations) {
|
||||
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
|
||||
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
|
||||
if (vStart > vEnd) continue;
|
||||
const absCursor = new Date(vStart);
|
||||
absCursor.setUTCHours(0, 0, 0, 0);
|
||||
const absEndNorm = new Date(vEnd);
|
||||
absEndNorm.setUTCHours(0, 0, 0, 0);
|
||||
const triggerType = v.type === "SICK" ? "SICK" as const
|
||||
: v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const
|
||||
: "VACATION" as const;
|
||||
while (absCursor <= absEndNorm) {
|
||||
monthAbsenceDays.push({
|
||||
date: new Date(absCursor),
|
||||
type: triggerType,
|
||||
...(v.isHalfDay ? { isHalfDay: true } : {}),
|
||||
});
|
||||
absCursor.setUTCDate(absCursor.getUTCDate() + 1);
|
||||
const slices: AssignmentSlice[] = resourceAssignments.flatMap((a) => {
|
||||
const totalChargeableHours = calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
context,
|
||||
});
|
||||
if (totalChargeableHours <= 0) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Build assignment slices for this month, using rules to compute chargeable hours
|
||||
const slices: AssignmentSlice[] = [];
|
||||
for (const a of resourceAssignments) {
|
||||
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
|
||||
if (workingDays <= 0) continue;
|
||||
|
||||
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
|
||||
|
||||
// If there are absences and rules, compute rules-adjusted chargeable hours
|
||||
if (monthAbsenceDays.length > 0) {
|
||||
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
|
||||
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
|
||||
|
||||
const calcResult = calculateAllocation({
|
||||
lcrCents: 0, // we only need hours, not costs
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
startDate: overlapStart,
|
||||
endDate: overlapEnd,
|
||||
availability: { monday: dailyHours, tuesday: dailyHours, wednesday: dailyHours, thursday: dailyHours, friday: dailyHours, saturday: 0, sunday: 0 },
|
||||
absenceDays: monthAbsenceDays,
|
||||
calculationRules: calcRules,
|
||||
});
|
||||
|
||||
slices.push({
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
workingDays,
|
||||
categoryCode,
|
||||
...(calcResult.totalChargeableHours !== undefined ? { totalChargeableHours: calcResult.totalChargeableHours } : {}),
|
||||
});
|
||||
} else {
|
||||
slices.push({
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
workingDays,
|
||||
categoryCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
workingDays: 0,
|
||||
categoryCode: a.project.utilizationCategory?.code ?? "Chg",
|
||||
totalChargeableHours,
|
||||
};
|
||||
});
|
||||
|
||||
const forecast = deriveResourceForecast({
|
||||
fte: resource.fte,
|
||||
targetPercentage: targetPct,
|
||||
assignments: slices,
|
||||
sah: sahResult.standardAvailableHours,
|
||||
sah: availableHours,
|
||||
});
|
||||
|
||||
return {
|
||||
monthKey: key,
|
||||
sah: sahResult.standardAvailableHours,
|
||||
sah: availableHours,
|
||||
...forecast,
|
||||
};
|
||||
});
|
||||
}));
|
||||
|
||||
return {
|
||||
id: resource.id,
|
||||
@@ -278,7 +202,7 @@ export const chargeabilityReportRouter = createTRPCRouter({
|
||||
targetPct,
|
||||
months,
|
||||
};
|
||||
});
|
||||
}));
|
||||
|
||||
// Compute group totals per month
|
||||
const groupTotals = monthKeys.map((key, monthIdx) => {
|
||||
|
||||
Reference in New Issue
Block a user