Files
Nexus/packages/application/src/use-cases/dashboard/get-chargeability-overview.ts
T

309 lines
8.9 KiB
TypeScript

import type { PrismaClient } from "@capakraken/db";
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;
topN: number;
watchlistThreshold: number;
countryIds?: string[];
departed?: boolean;
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,
) {
const now = input.now ?? new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const resources = await db.resource.findMany({
where: {
isActive: true,
...(input.countryIds && input.countryIds.length > 0
? { countryId: { in: input.countryIds } }
: {}),
...(input.departed !== undefined ? { departed: input.departed } : {}),
},
select: {
id: true,
eid: true,
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,
},
});
const bookings = await listAssignmentBookings(db, {
startDate: start,
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: 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),
);
const expectedAllocations = resourceBookings.filter(
(booking) => isChargeabilityRelevantProject(booking.project, true),
);
const availableHours = calculateEffectiveAvailableHours({
availability,
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 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,
eid: resource.eid,
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,
actualChargeability,
expectedChargeability,
derivation: summarizeDerivation(
availability,
start,
end,
context,
actualBookedHours,
expectedBookedHours,
chargeabilityTarget,
),
};
});
return {
top: [...stats]
.sort((left, right) => right.actualChargeability - left.actualChargeability),
watchlist: [...stats]
.filter(
(resource) =>
resource.actualChargeability <
resource.chargeabilityTarget - input.watchlistThreshold,
)
.sort((left, right) => left.actualChargeability - right.actualChargeability),
month: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`,
};
}