309 lines
8.9 KiB
TypeScript
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")}`,
|
|
};
|
|
}
|