feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -1,11 +1,26 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import { computeChargeability } from "@capakraken/engine";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import {
|
||||
isChargeabilityActualBooking,
|
||||
isChargeabilityRelevantProject,
|
||||
} from "../allocation/chargeability-bookings.js";
|
||||
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
|
||||
import {
|
||||
calculateEffectiveAllocationHours,
|
||||
calculateEffectiveAvailableHours,
|
||||
loadDailyAvailabilityContexts,
|
||||
type DailyAvailabilityContext,
|
||||
} from "./holiday-capacity.js";
|
||||
|
||||
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
];
|
||||
|
||||
export interface GetDashboardChargeabilityOverviewInput {
|
||||
includeProposed?: boolean;
|
||||
@@ -16,6 +31,132 @@ export interface GetDashboardChargeabilityOverviewInput {
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export interface DashboardChargeabilityDerivation {
|
||||
weeklyAvailabilityHours: number;
|
||||
baseWorkingDays: number;
|
||||
effectiveWorkingDayEquivalent: number;
|
||||
baseAvailableHours: number;
|
||||
effectiveAvailableHours: number;
|
||||
publicHolidayCount: number;
|
||||
publicHolidayWorkdayCount: number;
|
||||
publicHolidayHoursDeduction: number;
|
||||
absenceDayEquivalent: number;
|
||||
absenceHoursDeduction: number;
|
||||
actualBookedHours: number;
|
||||
expectedBookedHours: number;
|
||||
targetBookedHours: number;
|
||||
unassignedHours: number;
|
||||
}
|
||||
|
||||
export interface DashboardChargeabilityRow {
|
||||
id: string;
|
||||
eid: string;
|
||||
displayName: string;
|
||||
chapter: string | null;
|
||||
countryId?: string | null;
|
||||
countryCode?: string | null;
|
||||
countryName?: string | null;
|
||||
federalState?: string | null;
|
||||
metroCityName?: string | null;
|
||||
departed: boolean | null;
|
||||
chargeabilityTarget: number;
|
||||
actualChargeability: number;
|
||||
expectedChargeability: number;
|
||||
derivation?: DashboardChargeabilityDerivation;
|
||||
}
|
||||
|
||||
function toIsoDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function getDailyAvailabilityHours(
|
||||
availability: WeekdayAvailability,
|
||||
date: Date,
|
||||
): number {
|
||||
const dayKey = DAY_KEYS[date.getUTCDay()];
|
||||
return dayKey ? (availability[dayKey] ?? 0) : 0;
|
||||
}
|
||||
|
||||
function summarizeDerivation(
|
||||
availability: WeekdayAvailability,
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
context: DailyAvailabilityContext | undefined,
|
||||
actualBookedHours: number,
|
||||
expectedBookedHours: number,
|
||||
chargeabilityTarget: number,
|
||||
): DashboardChargeabilityDerivation {
|
||||
let baseWorkingDays = 0;
|
||||
let effectiveWorkingDayEquivalent = 0;
|
||||
let publicHolidayWorkdayCount = 0;
|
||||
let publicHolidayHoursDeduction = 0;
|
||||
let absenceDayEquivalent = 0;
|
||||
let absenceHoursDeduction = 0;
|
||||
|
||||
const weeklyAvailabilityHours = Object.values(availability).reduce(
|
||||
(sum, hours) => sum + (hours ?? 0),
|
||||
0,
|
||||
);
|
||||
const baseAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context: undefined,
|
||||
});
|
||||
const effectiveAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context,
|
||||
});
|
||||
|
||||
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 baseHours = getDailyAvailabilityHours(availability, cursor);
|
||||
const absenceFraction = Math.min(
|
||||
1,
|
||||
Math.max(0, context?.absenceFractionsByDate.get(isoDate) ?? 0),
|
||||
);
|
||||
const isHoliday = context?.holidayDates.has(isoDate) ?? false;
|
||||
|
||||
if (baseHours > 0) {
|
||||
baseWorkingDays += 1;
|
||||
if (isHoliday) {
|
||||
publicHolidayWorkdayCount += 1;
|
||||
publicHolidayHoursDeduction += baseHours;
|
||||
} else {
|
||||
absenceDayEquivalent += absenceFraction;
|
||||
absenceHoursDeduction += baseHours * absenceFraction;
|
||||
effectiveWorkingDayEquivalent += Math.max(0, 1 - absenceFraction);
|
||||
}
|
||||
}
|
||||
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
weeklyAvailabilityHours,
|
||||
baseWorkingDays,
|
||||
effectiveWorkingDayEquivalent,
|
||||
baseAvailableHours,
|
||||
effectiveAvailableHours,
|
||||
publicHolidayCount: context?.holidayDates.size ?? 0,
|
||||
publicHolidayWorkdayCount,
|
||||
publicHolidayHoursDeduction,
|
||||
absenceDayEquivalent,
|
||||
absenceHoursDeduction,
|
||||
actualBookedHours,
|
||||
expectedBookedHours,
|
||||
targetBookedHours: Math.round((effectiveAvailableHours * chargeabilityTarget) / 10) / 10,
|
||||
unassignedHours: Math.max(0, effectiveAvailableHours - expectedBookedHours),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDashboardChargeabilityOverview(
|
||||
db: PrismaClient,
|
||||
input: GetDashboardChargeabilityOverviewInput,
|
||||
@@ -38,8 +179,23 @@ export async function getDashboardChargeabilityOverview(
|
||||
displayName: true,
|
||||
chapter: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
departed: true,
|
||||
chargeabilityTarget: true,
|
||||
country: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
metroCity: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
availability: true,
|
||||
},
|
||||
});
|
||||
@@ -48,9 +204,24 @@ export async function getDashboardChargeabilityOverview(
|
||||
endDate: end,
|
||||
resourceIds: resources.map((resource) => resource.id),
|
||||
});
|
||||
const contexts = await loadDailyAvailabilityContexts(
|
||||
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 stats = resources.map((resource) => {
|
||||
const stats: DashboardChargeabilityRow[] = resources.map((resource) => {
|
||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||
const context = contexts.get(resource.id);
|
||||
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
|
||||
const actualAllocations = resourceBookings.filter((booking) =>
|
||||
isChargeabilityActualBooking(booking, input.includeProposed === true),
|
||||
@@ -58,18 +229,43 @@ export async function getDashboardChargeabilityOverview(
|
||||
const expectedAllocations = resourceBookings.filter(
|
||||
(booking) => isChargeabilityRelevantProject(booking.project, true),
|
||||
);
|
||||
const actual = computeChargeability(
|
||||
const availableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
actualAllocations,
|
||||
start,
|
||||
end,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
});
|
||||
const actualBookedHours = actualAllocations.reduce(
|
||||
(sum, allocation) => sum + calculateEffectiveAllocationHours({
|
||||
availability,
|
||||
startDate: allocation.startDate,
|
||||
endDate: allocation.endDate,
|
||||
hoursPerDay: allocation.hoursPerDay,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const expected = computeChargeability(
|
||||
availability,
|
||||
expectedAllocations,
|
||||
start,
|
||||
end,
|
||||
const expectedBookedHours = expectedAllocations.reduce(
|
||||
(sum, allocation) => sum + calculateEffectiveAllocationHours({
|
||||
availability,
|
||||
startDate: allocation.startDate,
|
||||
endDate: allocation.endDate,
|
||||
hoursPerDay: allocation.hoursPerDay,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const actualChargeability = availableHours > 0
|
||||
? Math.min(100, Math.round((actualBookedHours / availableHours) * 100))
|
||||
: 0;
|
||||
const expectedChargeability = availableHours > 0
|
||||
? Math.min(100, Math.round((expectedBookedHours / availableHours) * 100))
|
||||
: 0;
|
||||
const chargeabilityTarget = resource.chargeabilityTarget ?? 0;
|
||||
|
||||
return {
|
||||
id: resource.id,
|
||||
@@ -77,10 +273,23 @@ export async function getDashboardChargeabilityOverview(
|
||||
displayName: resource.displayName,
|
||||
chapter: resource.chapter,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code ?? null,
|
||||
countryName: resource.country?.name ?? null,
|
||||
federalState: resource.federalState ?? null,
|
||||
metroCityName: resource.metroCity?.name ?? null,
|
||||
departed: resource.departed,
|
||||
chargeabilityTarget: resource.chargeabilityTarget,
|
||||
actualChargeability: actual.chargeability,
|
||||
expectedChargeability: expected.chargeability,
|
||||
chargeabilityTarget,
|
||||
actualChargeability,
|
||||
expectedChargeability,
|
||||
derivation: summarizeDerivation(
|
||||
availability,
|
||||
start,
|
||||
end,
|
||||
context,
|
||||
actualBookedHours,
|
||||
expectedBookedHours,
|
||||
chargeabilityTarget,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user