feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -4,18 +4,27 @@ import {
|
||||
deriveResourceForecast,
|
||||
computeBudgetStatus,
|
||||
getMonthRange,
|
||||
countWorkingDaysInOverlap,
|
||||
DEFAULT_CALCULATION_RULES,
|
||||
summarizeEstimateDemandLines,
|
||||
computeEvenSpread,
|
||||
distributeHoursToWeeks,
|
||||
type AssignmentSlice,
|
||||
} from "@capakraken/engine";
|
||||
import type { CalculationRule, AbsenceDay, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared";
|
||||
import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared";
|
||||
import { VacationStatus } from "@capakraken/db";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
||||
import { fmtEur } from "../lib/format-utils.js";
|
||||
import {
|
||||
asHolidayResolverDb,
|
||||
collectHolidayAvailability,
|
||||
getResolvedCalendarHolidays,
|
||||
} from "../lib/holiday-availability.js";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
countEffectiveWorkingDays,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "../lib/resource-capacity.js";
|
||||
|
||||
// ─── Graph Types (mirrored from client for API response) ────────────────────
|
||||
|
||||
@@ -62,6 +71,21 @@ function fmtNum(v: number, decimals = 1): string {
|
||||
return v.toFixed(decimals);
|
||||
}
|
||||
|
||||
function getAvailabilityHoursForDate(
|
||||
availability: WeekdayAvailability,
|
||||
date: Date,
|
||||
): number {
|
||||
const dayKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][date.getUTCDay()] as keyof WeekdayAvailability;
|
||||
return availability[dayKey] ?? 0;
|
||||
}
|
||||
|
||||
function sumAvailabilityHoursForDates(
|
||||
availability: WeekdayAvailability,
|
||||
dates: Date[],
|
||||
): number {
|
||||
return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0);
|
||||
}
|
||||
|
||||
// ─── Router ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const computationGraphRouter = createTRPCRouter({
|
||||
@@ -88,8 +112,12 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
fte: true,
|
||||
lcrCents: true,
|
||||
chargeabilityTarget: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
availability: true,
|
||||
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
|
||||
country: { select: { id: true, code: true, name: true, dailyWorkingHours: true, scheduleRules: true } },
|
||||
metroCity: { select: { id: true, name: true } },
|
||||
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
|
||||
},
|
||||
});
|
||||
@@ -133,7 +161,7 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
|
||||
// ── 3. Load absences ──
|
||||
// ── 3. Load absences + holiday context ──
|
||||
const vacations = await ctx.db.vacation.findMany({
|
||||
where: {
|
||||
resourceId: input.resourceId,
|
||||
@@ -143,45 +171,47 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
},
|
||||
select: { startDate: true, endDate: true, type: true, isHalfDay: true },
|
||||
});
|
||||
const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
});
|
||||
const holidayAvailability = collectHolidayAvailability({
|
||||
vacations,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
resolvedHolidayStrings: resolvedHolidays.map((holiday) => holiday.date),
|
||||
});
|
||||
const publicHolidayStrings = holidayAvailability.publicHolidayStrings;
|
||||
const absenceDateStrings = holidayAvailability.absenceDateStrings;
|
||||
const absenceDays = holidayAvailability.absenceDays;
|
||||
const halfDayCount = absenceDays.filter((absence) => absence.isHalfDay).length;
|
||||
const vacationDayCount = absenceDays.filter((absence) => absence.type === "VACATION").length;
|
||||
const sickDayCount = absenceDays.filter((absence) => absence.type === "SICK").length;
|
||||
const publicHolidayCount = resolvedHolidays.length;
|
||||
|
||||
// Build absence dates for SAH (ISO strings), separating public holidays
|
||||
const publicHolidayStrings: string[] = [];
|
||||
const absenceDateStrings: string[] = [];
|
||||
const absenceDays: AbsenceDay[] = [];
|
||||
let halfDayCount = 0;
|
||||
let vacationDayCount = 0;
|
||||
let sickDayCount = 0;
|
||||
let publicHolidayCount = 0;
|
||||
for (const v of vacations) {
|
||||
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);
|
||||
const triggerType = v.type === "SICK" ? "SICK" as const
|
||||
: v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const
|
||||
: "VACATION" as const;
|
||||
while (cursor <= endNorm) {
|
||||
const isoDate = cursor.toISOString().slice(0, 10);
|
||||
if (triggerType === "PUBLIC_HOLIDAY") {
|
||||
publicHolidayStrings.push(isoDate);
|
||||
publicHolidayCount++;
|
||||
} else {
|
||||
absenceDateStrings.push(isoDate);
|
||||
if (triggerType === "VACATION") vacationDayCount++;
|
||||
if (triggerType === "SICK") sickDayCount++;
|
||||
}
|
||||
absenceDays.push({
|
||||
date: new Date(cursor),
|
||||
type: triggerType,
|
||||
...(v.isHalfDay ? { isHalfDay: true } : {}),
|
||||
});
|
||||
if (v.isHalfDay) halfDayCount++;
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.db,
|
||||
[{
|
||||
id: resource.id,
|
||||
availability: weeklyAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
}],
|
||||
monthStart,
|
||||
monthEnd,
|
||||
);
|
||||
const availabilityContext = contexts.get(resource.id);
|
||||
|
||||
// ── 4. Load calculation rules ──
|
||||
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
|
||||
@@ -197,7 +227,7 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
// table may not exist yet
|
||||
}
|
||||
|
||||
// ── 5. Calculate SAH ──
|
||||
// ── 5. Calculate SAH / effective capacity ──
|
||||
const sahResult = calculateSAH({
|
||||
dailyWorkingHours: dailyHours,
|
||||
scheduleRules,
|
||||
@@ -207,6 +237,60 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
publicHolidays: publicHolidayStrings,
|
||||
absenceDays: absenceDateStrings,
|
||||
});
|
||||
const baseWorkingDays = countEffectiveWorkingDays({
|
||||
availability: weeklyAvailability,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
context: undefined,
|
||||
});
|
||||
const effectiveWorkingDays = countEffectiveWorkingDays({
|
||||
availability: weeklyAvailability,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
context: availabilityContext,
|
||||
});
|
||||
const baseAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability: weeklyAvailability,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
context: undefined,
|
||||
});
|
||||
const effectiveAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability: weeklyAvailability,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
context: availabilityContext,
|
||||
});
|
||||
const publicHolidayDates = resolvedHolidays.map((holiday) => new Date(`${holiday.date}T00:00:00.000Z`));
|
||||
const publicHolidayWorkdayCount = publicHolidayDates.reduce((count, date) => (
|
||||
count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0)
|
||||
), 0);
|
||||
const publicHolidayHoursDeduction = sumAvailabilityHoursForDates(
|
||||
weeklyAvailability,
|
||||
publicHolidayDates,
|
||||
);
|
||||
const absenceHoursDeduction = absenceDays.reduce((sum, absence) => {
|
||||
if (absence.type === "PUBLIC_HOLIDAY") {
|
||||
return sum;
|
||||
}
|
||||
const baseHours = getAvailabilityHoursForDate(weeklyAvailability, absence.date);
|
||||
return sum + baseHours * (absence.isHalfDay ? 0.5 : 1);
|
||||
}, 0);
|
||||
const effectiveHoursPerWorkingDay = effectiveWorkingDays > 0
|
||||
? effectiveAvailableHours / effectiveWorkingDays
|
||||
: 0;
|
||||
const holidayScopeSummary = [
|
||||
resource.country?.code ?? "—",
|
||||
resource.federalState ?? "—",
|
||||
resource.metroCity?.name ?? "—",
|
||||
].join(" / ");
|
||||
const holidayExamples = resolvedHolidays.length > 0
|
||||
? resolvedHolidays.slice(0, 4).map((holiday) => `${holiday.date} ${holiday.name}`).join(", ")
|
||||
: "none";
|
||||
const holidayScopeBreakdown = resolvedHolidays.reduce<Record<string, number>>((counts, holiday) => {
|
||||
counts[holiday.scope] = (counts[holiday.scope] ?? 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
|
||||
// ── 6. Calculate allocations + chargeability slices ──
|
||||
const slices: AssignmentSlice[] = [];
|
||||
@@ -217,9 +301,6 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
let hasRulesEffect = false;
|
||||
|
||||
for (const a of assignments) {
|
||||
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
|
||||
if (workingDays <= 0) continue;
|
||||
|
||||
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
|
||||
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
|
||||
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
|
||||
@@ -233,6 +314,7 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
absenceDays,
|
||||
calculationRules: calcRules,
|
||||
});
|
||||
if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) continue;
|
||||
|
||||
totalAllocHours += calcResult.totalHours;
|
||||
totalAllocCostCents += calcResult.totalCostCents;
|
||||
@@ -247,7 +329,7 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
|
||||
slices.push({
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
workingDays,
|
||||
workingDays: calcResult.workingDays,
|
||||
categoryCode,
|
||||
...(calcResult.totalChargeableHours !== undefined
|
||||
? { totalChargeableHours: calcResult.totalChargeableHours }
|
||||
@@ -260,7 +342,7 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
fte: resource.fte,
|
||||
targetPercentage: targetPct,
|
||||
assignments: slices,
|
||||
sah: sahResult.standardAvailableHours,
|
||||
sah: effectiveAvailableHours,
|
||||
});
|
||||
|
||||
// ── 8. Build budget status for first project with budget ──
|
||||
@@ -319,7 +401,18 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
? assignments.reduce((sum, a) => sum + a.hoursPerDay, 0) / assignments.length
|
||||
: 0;
|
||||
const totalWorkingDaysInMonth = assignments.reduce((sum, a) => {
|
||||
return sum + countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
|
||||
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: resource.lcrCents,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
startDate: overlapStart,
|
||||
endDate: overlapEnd,
|
||||
availability: weeklyAvailability,
|
||||
absenceDays,
|
||||
calculationRules: calcRules,
|
||||
});
|
||||
return sum + calcResult.workingDays;
|
||||
}, 0);
|
||||
|
||||
// Format weekly availability for display
|
||||
@@ -332,9 +425,10 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
: weekdayLabels.map((d, i) => `${d}:${weekdayValues[i]}`).join(" ");
|
||||
|
||||
// Derived utilization ratio
|
||||
const utilizationPct = sahResult.standardAvailableHours > 0
|
||||
? (totalAllocHours / sahResult.standardAvailableHours) * 100
|
||||
const utilizationPct = effectiveAvailableHours > 0
|
||||
? (totalAllocHours / effectiveAvailableHours) * 100
|
||||
: 0;
|
||||
const chargeableHours = forecast.chg * effectiveAvailableHours;
|
||||
|
||||
// Has schedule rules (Spain variable hours)?
|
||||
const hasScheduleRules = !!scheduleRules;
|
||||
@@ -342,6 +436,11 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
const nodes: GraphNode[] = [
|
||||
// INPUT
|
||||
n("input.fte", "FTE", fmtNum(resource.fte, 2), "ratio", "INPUT", `Resource FTE factor`, 0),
|
||||
n("input.country", "Country", resource.country?.name ?? resource.country?.code ?? "—", "text", "INPUT", "Country used for base working-time and national holiday rules", 0),
|
||||
n("input.state", "State", resource.federalState ?? "—", "text", "INPUT", "Federal state / region used for regional holidays", 0),
|
||||
n("input.city", "City", resource.metroCity?.name ?? "—", "text", "INPUT", "City / metro used for local holidays", 0),
|
||||
n("input.holidayContext", "Holiday Context", holidayScopeSummary, "text", "INPUT", "Resolved holiday scope chain: country / state / city", 0),
|
||||
n("input.holidayExamples", "Holiday Dates", holidayExamples, "text", "INPUT", `Resolved holidays in ${input.month}; scopes: COUNTRY ${holidayScopeBreakdown.COUNTRY ?? 0}, STATE ${holidayScopeBreakdown.STATE ?? 0}, CITY ${holidayScopeBreakdown.CITY ?? 0}`, 0),
|
||||
n("input.dailyHours", "Country Hours", `${dailyHours} h`, "hours", "INPUT", `Base daily working hours (${resource.country?.code ?? "?"})`, 0),
|
||||
...(hasScheduleRules ? [
|
||||
n("input.scheduleRules", "Schedule Rules", "Spain", "—", "INPUT", "Variable daily hours (regular/friday/summer)", 0),
|
||||
@@ -350,7 +449,7 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
n("input.lcrCents", "LCR", fmtEur(resource.lcrCents), "cents/h", "INPUT", "Loaded Cost Rate per hour", 0),
|
||||
n("input.hoursPerDay", "Hours/Day", fmtNum(avgHoursPerDay), "hours", "INPUT", "Average hours/day across assignments", 0),
|
||||
n("input.absences", "Absences", `${absenceDays.length}`, "count", "INPUT", `Absence days in ${input.month} (${vacationDayCount} vacation, ${sickDayCount} sick${halfDayCount > 0 ? `, ${halfDayCount} half-day` : ""})`, 0),
|
||||
n("input.publicHolidays", "Public Holidays", `${publicHolidayCount}`, "count", "INPUT", `Public holidays in ${input.month}`, 0),
|
||||
n("input.publicHolidays", "Public Holidays", `${publicHolidayCount}`, "count", "INPUT", `Resolved holidays in ${input.month}; ${publicHolidayWorkdayCount} hit configured working days`, 0),
|
||||
n("input.calcRules", "Active Rules", `${calcRules.length}`, "count", "INPUT", "Active calculation rules", 0),
|
||||
n("input.targetPct", "Target", fmtPct(targetPct), "%", "INPUT", `Chargeability target (${resource.managementLevelGroup?.name ?? "legacy"})`, 0),
|
||||
n("input.assignmentCount", "Assignments", `${assignments.length}`, "count", "INPUT", `Active assignments in ${input.month}`, 0),
|
||||
@@ -358,12 +457,15 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
// SAH
|
||||
n("sah.calendarDays", "Calendar Days", `${sahResult.calendarDays}`, "days", "SAH", "Total calendar days in period", 1),
|
||||
n("sah.weekendDays", "Weekend Days", `${sahResult.weekendDays}`, "days", "SAH", "Saturday + Sunday count", 1),
|
||||
n("sah.grossWorkingDays", "Gross Work Days", `${sahResult.grossWorkingDays}`, "days", "SAH", "Calendar days minus weekends", 1, "calendarDays - weekendDays"),
|
||||
n("sah.publicHolidayDays", "Holiday Ded.", `${sahResult.publicHolidayDays}`, "days", "SAH", "Public holidays falling on working days", 1),
|
||||
n("sah.absenceDays", "Absence Ded.", `${sahResult.absenceDays}`, "days", "SAH", "Absences (vacation/sick) falling on working days", 1),
|
||||
n("sah.netWorkingDays", "Net Work Days", `${sahResult.netWorkingDays}`, "days", "SAH", "Working days after deductions", 2, "gross - holidays - absences"),
|
||||
n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(sahResult.effectiveHoursPerDay), "hours", "SAH", "Average effective hours per net working day (FTE-scaled)", 2, "Σ(dailyHours × FTE) / netDays"),
|
||||
n("sah.sah", "SAH", fmtNum(sahResult.standardAvailableHours), "hours", "SAH", "Standard Available Hours — chargeability denominator", 2, "Σ(dailyHours × FTE) per net day"),
|
||||
n("sah.grossWorkingDays", "Gross Work Days", `${baseWorkingDays}`, "days", "SAH", "Working days from the resource-specific weekly availability before holidays/absences", 1, "count(availability > 0)"),
|
||||
n("sah.baseHours", "Base Hours", fmtNum(baseAvailableHours), "hours", "SAH", "Available hours from weekly availability before holiday/absence deductions", 1, "Σ(daily availability)"),
|
||||
n("sah.publicHolidayDays", "Holiday Ded.", `${publicHolidayWorkdayCount}`, "days", "SAH", "Holiday workdays deducted after applying country/state/city scope and weekday availability", 1),
|
||||
n("sah.publicHolidayHours", "Holiday Hrs Ded.", fmtNum(publicHolidayHoursDeduction), "hours", "SAH", "Hours removed by resolved public holidays", 1, "Σ(availability on holiday dates)"),
|
||||
n("sah.absenceDays", "Absence Ded.", `${absenceDateStrings.length}`, "days", "SAH", "Vacation/sick days that hit working days and are not already public holidays", 1),
|
||||
n("sah.absenceHours", "Absence Hrs Ded.", fmtNum(absenceHoursDeduction), "hours", "SAH", "Hours removed by vacation/sick absences", 1, "Σ(availability × absence fraction)"),
|
||||
n("sah.netWorkingDays", "Net Work Days", `${effectiveWorkingDays}`, "days", "SAH", "Remaining working days after holiday and absence deductions", 2, "gross - holidays - absences"),
|
||||
n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(effectiveHoursPerWorkingDay), "hours", "SAH", "Average effective hours per remaining working day", 2, "SAH / net work days"),
|
||||
n("sah.sah", "SAH", fmtNum(effectiveAvailableHours), "hours", "SAH", "Effective available hours after weekly availability, local holidays and absences", 2, "base hours - holiday hours - absence hours"),
|
||||
|
||||
// ALLOCATION
|
||||
n("alloc.workingDays", "Work Days", `${totalWorkingDaysInMonth}`, "days", "ALLOCATION", "Working days covered by assignments in period", 1, "Σ(overlap workdays)"),
|
||||
@@ -387,24 +489,24 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
] : []),
|
||||
|
||||
// CHARGEABILITY — full breakdown from deriveResourceForecast
|
||||
n("chg.chgHours", "Chg Hours", fmtNum(forecast.chg * sahResult.standardAvailableHours), "hours", "CHARGEABILITY", "Total chargeable hours", 2, "Σ(Chg-category slices)"),
|
||||
n("chg.chgHours", "Chg Hours", fmtNum(chargeableHours), "hours", "CHARGEABILITY", "Total chargeable hours against effective SAH", 2, "chargeability × SAH"),
|
||||
n("chg.chg", "Chargeability", fmtPct(forecast.chg), "%", "CHARGEABILITY", "Chargeability ratio", 3, "chgHours / SAH"),
|
||||
...(forecast.bd > 0 ? [
|
||||
n("chg.bd", "BD Ratio", fmtPct(forecast.bd), "%", "CHARGEABILITY", `Business development: ${fmtNum(forecast.bd * sahResult.standardAvailableHours)}h`, 3, "bdHours / SAH"),
|
||||
n("chg.bd", "BD Ratio", fmtPct(forecast.bd), "%", "CHARGEABILITY", `Business development: ${fmtNum(forecast.bd * effectiveAvailableHours)}h`, 3, "bdHours / SAH"),
|
||||
] : []),
|
||||
...(forecast.mdi > 0 ? [
|
||||
n("chg.mdi", "MD&I Ratio", fmtPct(forecast.mdi), "%", "CHARGEABILITY", `MD&I hours: ${fmtNum(forecast.mdi * sahResult.standardAvailableHours)}h`, 3, "mdiHours / SAH"),
|
||||
n("chg.mdi", "MD&I Ratio", fmtPct(forecast.mdi), "%", "CHARGEABILITY", `MD&I hours: ${fmtNum(forecast.mdi * effectiveAvailableHours)}h`, 3, "mdiHours / SAH"),
|
||||
] : []),
|
||||
...(forecast.mo > 0 ? [
|
||||
n("chg.mo", "M&O Ratio", fmtPct(forecast.mo), "%", "CHARGEABILITY", `M&O hours: ${fmtNum(forecast.mo * sahResult.standardAvailableHours)}h`, 3, "moHours / SAH"),
|
||||
n("chg.mo", "M&O Ratio", fmtPct(forecast.mo), "%", "CHARGEABILITY", `M&O hours: ${fmtNum(forecast.mo * effectiveAvailableHours)}h`, 3, "moHours / SAH"),
|
||||
] : []),
|
||||
...(forecast.pdr > 0 ? [
|
||||
n("chg.pdr", "PD&R Ratio", fmtPct(forecast.pdr), "%", "CHARGEABILITY", `PD&R hours: ${fmtNum(forecast.pdr * sahResult.standardAvailableHours)}h`, 3, "pdrHours / SAH"),
|
||||
n("chg.pdr", "PD&R Ratio", fmtPct(forecast.pdr), "%", "CHARGEABILITY", `PD&R hours: ${fmtNum(forecast.pdr * effectiveAvailableHours)}h`, 3, "pdrHours / SAH"),
|
||||
] : []),
|
||||
...(forecast.absence > 0 ? [
|
||||
n("chg.absence", "Absence Ratio", fmtPct(forecast.absence), "%", "CHARGEABILITY", `Absence hours: ${fmtNum(forecast.absence * sahResult.standardAvailableHours)}h`, 3, "absenceHours / SAH"),
|
||||
n("chg.absence", "Absence Ratio", fmtPct(forecast.absence), "%", "CHARGEABILITY", `Absence hours: ${fmtNum(forecast.absence * effectiveAvailableHours)}h`, 3, "absenceHours / SAH"),
|
||||
] : []),
|
||||
n("chg.unassigned", "Unassigned", fmtPct(forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(forecast.unassigned * sahResult.standardAvailableHours)}h of ${fmtNum(sahResult.standardAvailableHours)}h SAH not assigned`, 3, "max(0, SAH - assigned) / SAH"),
|
||||
n("chg.unassigned", "Unassigned", fmtPct(forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(forecast.unassigned * effectiveAvailableHours)}h of ${fmtNum(effectiveAvailableHours)}h SAH not assigned`, 3, "max(0, SAH - assigned) / SAH"),
|
||||
n("chg.target", "Target", fmtPct(targetPct), "%", "CHARGEABILITY", "Chargeability target from management level", 3),
|
||||
n("chg.gap", "Gap to Target", `${forecast.chg - targetPct >= 0 ? "+" : ""}${((forecast.chg - targetPct) * 100).toFixed(1)} pp`, "pp", "CHARGEABILITY", `Chargeability (${fmtPct(forecast.chg)}) vs. target (${fmtPct(targetPct)})`, 3, "chargeability − target"),
|
||||
|
||||
@@ -414,7 +516,16 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
|
||||
const links: GraphLink[] = [
|
||||
// INPUT → SAH
|
||||
l("input.country", "input.holidayContext", "holiday base", 1),
|
||||
l("input.state", "input.holidayContext", "regional scope", 1),
|
||||
l("input.city", "input.holidayContext", "local scope", 1),
|
||||
l("input.holidayContext", "input.holidayExamples", "resolve holidays", 1),
|
||||
l("input.dailyHours", "sah.grossWorkingDays", "base hours", 1),
|
||||
l("input.weeklyAvail", "sah.grossWorkingDays", "working-day pattern", 2),
|
||||
l("input.weeklyAvail", "sah.baseHours", "sum by weekday", 2),
|
||||
l("input.holidayExamples", "sah.publicHolidayDays", "resolved dates", 2),
|
||||
l("input.holidayExamples", "sah.publicHolidayHours", "remove matching day hours", 2),
|
||||
l("input.absences", "sah.absenceHours", "remove absence fractions", 1),
|
||||
...(hasScheduleRules ? [
|
||||
l("input.scheduleRules", "sah.effectiveHoursPerDay", "variable h/day", 1),
|
||||
] : []),
|
||||
@@ -422,14 +533,14 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
l("sah.weekendDays", "sah.grossWorkingDays", "−", 1),
|
||||
l("input.publicHolidays", "sah.publicHolidayDays", "∩ workdays", 1),
|
||||
l("input.absences", "sah.absenceDays", "∩ workdays", 1),
|
||||
l("sah.grossWorkingDays", "sah.netWorkingDays", "−", 2),
|
||||
l("sah.grossWorkingDays", "sah.netWorkingDays", "− holiday/absence days", 2),
|
||||
l("sah.publicHolidayDays", "sah.netWorkingDays", "−", 1),
|
||||
l("sah.absenceDays", "sah.netWorkingDays", "−", 1),
|
||||
l("input.dailyHours", "sah.effectiveHoursPerDay", "×", 1),
|
||||
l("input.fte", "sah.effectiveHoursPerDay", "× FTE", 2),
|
||||
l("sah.baseHours", "sah.sah", "start from base capacity", 2),
|
||||
l("sah.publicHolidayHours", "sah.sah", "− holiday hours", 2),
|
||||
l("sah.absenceHours", "sah.sah", "− absence hours", 2),
|
||||
l("sah.sah", "sah.effectiveHoursPerDay", "÷", 1),
|
||||
l("sah.netWorkingDays", "sah.effectiveHoursPerDay", "÷", 1),
|
||||
l("sah.effectiveHoursPerDay", "sah.sah", "× netDays", 2),
|
||||
l("sah.netWorkingDays", "sah.sah", "×", 2),
|
||||
|
||||
// INPUT → ALLOCATION
|
||||
l("input.weeklyAvail", "alloc.totalHours", "caps h/day", 2),
|
||||
@@ -489,6 +600,30 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
resourceEid: resource.eid,
|
||||
month: input.month,
|
||||
assignmentCount: assignments.length,
|
||||
countryCode: resource.country?.code ?? null,
|
||||
countryName: resource.country?.name ?? null,
|
||||
federalState: resource.federalState ?? null,
|
||||
metroCityName: resource.metroCity?.name ?? null,
|
||||
resolvedHolidays: resolvedHolidays.map((holiday) => ({
|
||||
date: holiday.date,
|
||||
name: holiday.name,
|
||||
scope: holiday.scope,
|
||||
calendarName: holiday.calendarName,
|
||||
})),
|
||||
factors: {
|
||||
weeklyAvailability,
|
||||
baseWorkingDays,
|
||||
effectiveWorkingDays,
|
||||
baseAvailableHours,
|
||||
effectiveAvailableHours,
|
||||
publicHolidayCount,
|
||||
publicHolidayWorkdayCount,
|
||||
publicHolidayHoursDeduction,
|
||||
absenceDayCount: absenceDateStrings.length,
|
||||
absenceHoursDeduction,
|
||||
chargeableHours,
|
||||
utilizationPct,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user