fix(dashboard): stabilize budget forecast derivation typing
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
type BudgetForecastRow,
|
||||||
getDashboardBudgetForecast,
|
getDashboardBudgetForecast,
|
||||||
getDashboardChargeabilityOverview,
|
getDashboardChargeabilityOverview,
|
||||||
getDashboardDemand,
|
getDashboardDemand,
|
||||||
@@ -66,6 +67,55 @@ type DashboardDemandInput = z.infer<typeof dashboardDemandInputSchema>;
|
|||||||
type DashboardDetailInput = z.infer<typeof dashboardDetailInputSchema>;
|
type DashboardDetailInput = z.infer<typeof dashboardDetailInputSchema>;
|
||||||
type DashboardChargeabilityOverviewInput = z.infer<typeof dashboardChargeabilityOverviewInputSchema>;
|
type DashboardChargeabilityOverviewInput = z.infer<typeof dashboardChargeabilityOverviewInputSchema>;
|
||||||
|
|
||||||
|
type DashboardBudgetForecastCalendarLocation = {
|
||||||
|
countryCode: string | null;
|
||||||
|
countryName: string | null;
|
||||||
|
federalState: string | null;
|
||||||
|
metroCityName: string | null;
|
||||||
|
activeAssignmentCount: number;
|
||||||
|
burnRateCents: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DashboardBudgetForecastDerivation = {
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
calendarContextCount: number;
|
||||||
|
holidayAwareAssignmentCount: number;
|
||||||
|
fallbackAssignmentCount: number;
|
||||||
|
baseBurnRateCents: number;
|
||||||
|
adjustedBurnRateCents: number;
|
||||||
|
publicHolidayDayEquivalent: number;
|
||||||
|
publicHolidayCostDeductionCents: number;
|
||||||
|
absenceDayEquivalent: number;
|
||||||
|
absenceCostDeductionCents: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DashboardBudgetForecastDetail = {
|
||||||
|
forecasts: Array<{
|
||||||
|
projectId: string | null;
|
||||||
|
projectName: string;
|
||||||
|
shortCode: string;
|
||||||
|
clientId: string | null;
|
||||||
|
clientName: string | null;
|
||||||
|
budget: string;
|
||||||
|
budgetCents: number;
|
||||||
|
spent: string;
|
||||||
|
spentCents: number;
|
||||||
|
remaining: string;
|
||||||
|
remainingCents: number;
|
||||||
|
projected: string;
|
||||||
|
projectedCents: number;
|
||||||
|
burnRate: string;
|
||||||
|
burnRateCents: number;
|
||||||
|
utilization: string;
|
||||||
|
estimatedExhaustionDate: string | null;
|
||||||
|
activeAssignmentCount: number | null;
|
||||||
|
calendarLocations: DashboardBudgetForecastCalendarLocation[];
|
||||||
|
derivation: DashboardBudgetForecastDerivation | null;
|
||||||
|
burnStatus: "ahead" | "on_track" | "not_started";
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
function round1(value: number): number {
|
function round1(value: number): number {
|
||||||
return Math.round(value * 10) / 10;
|
return Math.round(value * 10) / 10;
|
||||||
}
|
}
|
||||||
@@ -98,7 +148,7 @@ function mapProjectHealthDetailRows(rows: Awaited<ReturnType<typeof getDashboard
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapBudgetForecastDetailRows(rows: Awaited<ReturnType<typeof getDashboardBudgetForecast>>) {
|
function mapBudgetForecastDetailRows(rows: BudgetForecastRow[]): DashboardBudgetForecastDetail {
|
||||||
return {
|
return {
|
||||||
forecasts: rows.map((forecast) => ({
|
forecasts: rows.map((forecast) => ({
|
||||||
projectId: forecast.projectId ?? null,
|
projectId: forecast.projectId ?? null,
|
||||||
@@ -124,6 +174,7 @@ function mapBudgetForecastDetailRows(rows: Awaited<ReturnType<typeof getDashboar
|
|||||||
estimatedExhaustionDate: forecast.estimatedExhaustionDate,
|
estimatedExhaustionDate: forecast.estimatedExhaustionDate,
|
||||||
activeAssignmentCount: forecast.activeAssignmentCount ?? null,
|
activeAssignmentCount: forecast.activeAssignmentCount ?? null,
|
||||||
calendarLocations: forecast.calendarLocations ?? [],
|
calendarLocations: forecast.calendarLocations ?? [],
|
||||||
|
derivation: forecast.derivation ?? null,
|
||||||
burnStatus: forecast.pctUsed >= 100
|
burnStatus: forecast.pctUsed >= 100
|
||||||
? "ahead"
|
? "ahead"
|
||||||
: forecast.burnRate > 0
|
: forecast.burnRate > 0
|
||||||
@@ -374,9 +425,11 @@ export async function getDashboardChargeabilityOverviewRead(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDashboardBudgetForecastRead(ctx: DashboardProcedureContext) {
|
export async function getDashboardBudgetForecastRead(
|
||||||
|
ctx: DashboardProcedureContext,
|
||||||
|
): Promise<BudgetForecastRow[]> {
|
||||||
const cacheKey = "budgetForecast";
|
const cacheKey = "budgetForecast";
|
||||||
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardBudgetForecast>>>(cacheKey);
|
const cached = await cacheGet<BudgetForecastRow[]>(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const result = await getDashboardBudgetForecast(ctx.db);
|
const result = await getDashboardBudgetForecast(ctx.db);
|
||||||
@@ -384,8 +437,10 @@ export async function getDashboardBudgetForecastRead(ctx: DashboardProcedureCont
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDashboardBudgetForecastDetail(ctx: DashboardProcedureContext) {
|
export async function getDashboardBudgetForecastDetail(
|
||||||
const budgetForecast = await getDashboardBudgetForecast(ctx.db);
|
ctx: DashboardProcedureContext,
|
||||||
|
): Promise<DashboardBudgetForecastDetail> {
|
||||||
|
const budgetForecast: BudgetForecastRow[] = await getDashboardBudgetForecast(ctx.db);
|
||||||
return mapBudgetForecastDetailRows(budgetForecast);
|
return mapBudgetForecastDetailRows(budgetForecast);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export {
|
|||||||
type DashboardChargeabilityDerivation,
|
type DashboardChargeabilityDerivation,
|
||||||
type DashboardChargeabilityRow,
|
type DashboardChargeabilityRow,
|
||||||
getDashboardBudgetForecast,
|
getDashboardBudgetForecast,
|
||||||
|
type BudgetForecastDerivationSummary,
|
||||||
type BudgetForecastRow,
|
type BudgetForecastRow,
|
||||||
type BudgetForecastLocationSummary,
|
type BudgetForecastLocationSummary,
|
||||||
type PeakTimesPeriodDerivation,
|
type PeakTimesPeriodDerivation,
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ export interface BudgetForecastLocationSummary {
|
|||||||
burnRateCents: number;
|
burnRateCents: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BudgetForecastDerivationSummary {
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
calendarContextCount: number;
|
||||||
|
holidayAwareAssignmentCount: number;
|
||||||
|
fallbackAssignmentCount: number;
|
||||||
|
baseBurnRateCents: number;
|
||||||
|
adjustedBurnRateCents: number;
|
||||||
|
publicHolidayDayEquivalent: number;
|
||||||
|
publicHolidayCostDeductionCents: number;
|
||||||
|
absenceDayEquivalent: number;
|
||||||
|
absenceCostDeductionCents: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BudgetForecastRow {
|
export interface BudgetForecastRow {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
@@ -29,6 +43,29 @@ export interface BudgetForecastRow {
|
|||||||
pctUsed: number;
|
pctUsed: number;
|
||||||
activeAssignmentCount?: number;
|
activeAssignmentCount?: number;
|
||||||
calendarLocations?: BudgetForecastLocationSummary[];
|
calendarLocations?: BudgetForecastLocationSummary[];
|
||||||
|
derivation?: BudgetForecastDerivationSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
|
||||||
|
"sunday",
|
||||||
|
"monday",
|
||||||
|
"tuesday",
|
||||||
|
"wednesday",
|
||||||
|
"thursday",
|
||||||
|
"friday",
|
||||||
|
"saturday",
|
||||||
|
];
|
||||||
|
|
||||||
|
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 hasAvailability<T extends { availability?: unknown }>(
|
function hasAvailability<T extends { availability?: unknown }>(
|
||||||
@@ -54,6 +91,86 @@ function buildLocationKey(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function summarizeBurnDerivation(input: {
|
||||||
|
availability: WeekdayAvailability;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
dailyCostCents: number;
|
||||||
|
periodStart: Date;
|
||||||
|
periodEnd: Date;
|
||||||
|
context: Awaited<ReturnType<typeof loadDailyAvailabilityContexts>> extends Map<string, infer TValue>
|
||||||
|
? TValue | undefined
|
||||||
|
: never;
|
||||||
|
}) {
|
||||||
|
const baseBurnRateCents = calculateEffectiveAllocationCostCents({
|
||||||
|
availability: input.availability,
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
|
dailyCostCents: input.dailyCostCents,
|
||||||
|
periodStart: input.periodStart,
|
||||||
|
periodEnd: input.periodEnd,
|
||||||
|
context: undefined,
|
||||||
|
});
|
||||||
|
const adjustedBurnRateCents = calculateEffectiveAllocationCostCents({
|
||||||
|
availability: input.availability,
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
|
dailyCostCents: input.dailyCostCents,
|
||||||
|
periodStart: input.periodStart,
|
||||||
|
periodEnd: input.periodEnd,
|
||||||
|
context: input.context,
|
||||||
|
});
|
||||||
|
|
||||||
|
const overlapStart = new Date(
|
||||||
|
Math.max(input.startDate.getTime(), input.periodStart.getTime()),
|
||||||
|
);
|
||||||
|
const overlapEnd = new Date(
|
||||||
|
Math.min(input.endDate.getTime(), input.periodEnd.getTime()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let publicHolidayDayEquivalent = 0;
|
||||||
|
let publicHolidayCostDeductionCents = 0;
|
||||||
|
let absenceDayEquivalent = 0;
|
||||||
|
let absenceCostDeductionCents = 0;
|
||||||
|
|
||||||
|
if (overlapStart <= overlapEnd) {
|
||||||
|
const cursor = new Date(overlapStart);
|
||||||
|
cursor.setUTCHours(0, 0, 0, 0);
|
||||||
|
const end = new Date(overlapEnd);
|
||||||
|
end.setUTCHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
while (cursor <= end) {
|
||||||
|
const baseHours = getDailyAvailabilityHours(input.availability, cursor);
|
||||||
|
if (baseHours > 0) {
|
||||||
|
const isoDate = toIsoDate(cursor);
|
||||||
|
if (input.context?.holidayDates.has(isoDate)) {
|
||||||
|
publicHolidayDayEquivalent += 1;
|
||||||
|
publicHolidayCostDeductionCents += input.dailyCostCents;
|
||||||
|
} else {
|
||||||
|
const vacationFraction = Math.min(
|
||||||
|
1,
|
||||||
|
Math.max(0, input.context?.vacationFractionsByDate.get(isoDate) ?? 0),
|
||||||
|
);
|
||||||
|
if (vacationFraction > 0) {
|
||||||
|
absenceDayEquivalent += vacationFraction;
|
||||||
|
absenceCostDeductionCents += input.dailyCostCents * vacationFraction;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseBurnRateCents,
|
||||||
|
adjustedBurnRateCents,
|
||||||
|
publicHolidayDayEquivalent,
|
||||||
|
publicHolidayCostDeductionCents: Math.round(publicHolidayCostDeductionCents),
|
||||||
|
absenceDayEquivalent,
|
||||||
|
absenceCostDeductionCents: Math.round(absenceCostDeductionCents),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function getDashboardBudgetForecast(
|
export async function getDashboardBudgetForecast(
|
||||||
db: PrismaClient,
|
db: PrismaClient,
|
||||||
): Promise<BudgetForecastRow[]> {
|
): Promise<BudgetForecastRow[]> {
|
||||||
@@ -148,6 +265,7 @@ export async function getDashboardBudgetForecast(
|
|||||||
const monthlyBurnByProject = new Map<string, number>();
|
const monthlyBurnByProject = new Map<string, number>();
|
||||||
const activeAssignmentCountByProject = new Map<string, number>();
|
const activeAssignmentCountByProject = new Map<string, number>();
|
||||||
const activeLocationsByProject = new Map<string, Map<string, BudgetForecastLocationSummary>>();
|
const activeLocationsByProject = new Map<string, Map<string, BudgetForecastLocationSummary>>();
|
||||||
|
const derivationByProject = new Map<string, Omit<BudgetForecastDerivationSummary, "periodStart" | "periodEnd" | "calendarContextCount">>();
|
||||||
|
|
||||||
for (const assignment of assignments) {
|
for (const assignment of assignments) {
|
||||||
const totalCost = hasAvailability(assignment.resource)
|
const totalCost = hasAvailability(assignment.resource)
|
||||||
@@ -169,8 +287,8 @@ export async function getDashboardBudgetForecast(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (assignment.startDate <= now && assignment.endDate >= now) {
|
if (assignment.startDate <= now && assignment.endDate >= now) {
|
||||||
const monthlyContribution = hasAvailability(assignment.resource)
|
const derivation = hasAvailability(assignment.resource)
|
||||||
? calculateEffectiveAllocationCostCents({
|
? summarizeBurnDerivation({
|
||||||
availability: assignment.resource.availability as unknown as WeekdayAvailability,
|
availability: assignment.resource.availability as unknown as WeekdayAvailability,
|
||||||
startDate: assignment.startDate,
|
startDate: assignment.startDate,
|
||||||
endDate: assignment.endDate,
|
endDate: assignment.endDate,
|
||||||
@@ -179,7 +297,9 @@ export async function getDashboardBudgetForecast(
|
|||||||
periodEnd: monthEnd,
|
periodEnd: monthEnd,
|
||||||
context: contexts.get(assignment.resource.id),
|
context: contexts.get(assignment.resource.id),
|
||||||
})
|
})
|
||||||
: (assignment.dailyCostCents ?? 0) * 22;
|
: null;
|
||||||
|
const monthlyContribution = derivation?.adjustedBurnRateCents ?? (assignment.dailyCostCents ?? 0) * 22;
|
||||||
|
const baseMonthlyContribution = derivation?.baseBurnRateCents ?? monthlyContribution;
|
||||||
monthlyBurnByProject.set(
|
monthlyBurnByProject.set(
|
||||||
assignment.projectId,
|
assignment.projectId,
|
||||||
(monthlyBurnByProject.get(assignment.projectId) ?? 0) + monthlyContribution,
|
(monthlyBurnByProject.get(assignment.projectId) ?? 0) + monthlyContribution,
|
||||||
@@ -188,6 +308,28 @@ export async function getDashboardBudgetForecast(
|
|||||||
assignment.projectId,
|
assignment.projectId,
|
||||||
(activeAssignmentCountByProject.get(assignment.projectId) ?? 0) + 1,
|
(activeAssignmentCountByProject.get(assignment.projectId) ?? 0) + 1,
|
||||||
);
|
);
|
||||||
|
const existingDerivation = derivationByProject.get(assignment.projectId) ?? {
|
||||||
|
holidayAwareAssignmentCount: 0,
|
||||||
|
fallbackAssignmentCount: 0,
|
||||||
|
baseBurnRateCents: 0,
|
||||||
|
adjustedBurnRateCents: 0,
|
||||||
|
publicHolidayDayEquivalent: 0,
|
||||||
|
publicHolidayCostDeductionCents: 0,
|
||||||
|
absenceDayEquivalent: 0,
|
||||||
|
absenceCostDeductionCents: 0,
|
||||||
|
};
|
||||||
|
existingDerivation.baseBurnRateCents += baseMonthlyContribution;
|
||||||
|
existingDerivation.adjustedBurnRateCents += monthlyContribution;
|
||||||
|
if (derivation) {
|
||||||
|
existingDerivation.holidayAwareAssignmentCount += 1;
|
||||||
|
existingDerivation.publicHolidayDayEquivalent += derivation.publicHolidayDayEquivalent;
|
||||||
|
existingDerivation.publicHolidayCostDeductionCents += derivation.publicHolidayCostDeductionCents;
|
||||||
|
existingDerivation.absenceDayEquivalent += derivation.absenceDayEquivalent;
|
||||||
|
existingDerivation.absenceCostDeductionCents += derivation.absenceCostDeductionCents;
|
||||||
|
} else {
|
||||||
|
existingDerivation.fallbackAssignmentCount += 1;
|
||||||
|
}
|
||||||
|
derivationByProject.set(assignment.projectId, existingDerivation);
|
||||||
|
|
||||||
const locationSummaries = activeLocationsByProject.get(assignment.projectId) ?? new Map();
|
const locationSummaries = activeLocationsByProject.get(assignment.projectId) ?? new Map();
|
||||||
const locationKey = buildLocationKey({
|
const locationKey = buildLocationKey({
|
||||||
@@ -228,6 +370,8 @@ export async function getDashboardBudgetForecast(
|
|||||||
estimatedExhaustionDate = exhaustionDate.toISOString().slice(0, 10);
|
estimatedExhaustionDate = exhaustionDate.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const derivation = derivationByProject.get(project.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
projectName: project.name,
|
projectName: project.name,
|
||||||
@@ -243,6 +387,16 @@ export async function getDashboardBudgetForecast(
|
|||||||
activeAssignmentCount: activeAssignmentCountByProject.get(project.id) ?? 0,
|
activeAssignmentCount: activeAssignmentCountByProject.get(project.id) ?? 0,
|
||||||
calendarLocations: Array.from(activeLocationsByProject.get(project.id)?.values() ?? [])
|
calendarLocations: Array.from(activeLocationsByProject.get(project.id)?.values() ?? [])
|
||||||
.sort((left, right) => right.burnRateCents - left.burnRateCents),
|
.sort((left, right) => right.burnRateCents - left.burnRateCents),
|
||||||
|
...(derivation
|
||||||
|
? {
|
||||||
|
derivation: {
|
||||||
|
periodStart: toIsoDate(monthStart),
|
||||||
|
periodEnd: toIsoDate(monthEnd),
|
||||||
|
calendarContextCount: activeLocationsByProject.get(project.id)?.size ?? 0,
|
||||||
|
...derivation,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export {
|
|||||||
export {
|
export {
|
||||||
getDashboardTopValueResources,
|
getDashboardTopValueResources,
|
||||||
type GetDashboardTopValueResourcesInput,
|
type GetDashboardTopValueResourcesInput,
|
||||||
|
type DashboardTopValueResourceRow,
|
||||||
} from "./get-top-value-resources.js";
|
} from "./get-top-value-resources.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -31,6 +32,7 @@ export {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
getDashboardBudgetForecast,
|
getDashboardBudgetForecast,
|
||||||
|
type BudgetForecastDerivationSummary,
|
||||||
type BudgetForecastRow,
|
type BudgetForecastRow,
|
||||||
type BudgetForecastLocationSummary,
|
type BudgetForecastLocationSummary,
|
||||||
} from "./get-budget-forecast.js";
|
} from "./get-budget-forecast.js";
|
||||||
|
|||||||
Reference in New Issue
Block a user