refactor(api): extract chargeability report procedures
This commit is contained in:
@@ -15,13 +15,13 @@ Done
|
|||||||
- `dispo`
|
- `dispo`
|
||||||
- `insights`
|
- `insights`
|
||||||
- `import-export`
|
- `import-export`
|
||||||
|
- `chargeability-report`
|
||||||
|
|
||||||
Ready next
|
Ready next
|
||||||
- none in the conflict-safe backlog
|
- none in the conflict-safe backlog
|
||||||
|
|
||||||
Deferred or blocked
|
Deferred or blocked
|
||||||
- `assistant-tools`
|
- `assistant-tools`
|
||||||
- `chargeability-report`
|
|
||||||
- `dashboard`
|
- `dashboard`
|
||||||
- `entitlement`
|
- `entitlement`
|
||||||
- `notification`
|
- `notification`
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { PermissionKey } from "@capakraken/shared";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildChargeabilityReportDetail,
|
||||||
|
getChargeabilityReportDetail,
|
||||||
|
} from "../router/chargeability-report-procedure-support.js";
|
||||||
|
|
||||||
|
describe("chargeability report procedure support", () => {
|
||||||
|
it("builds a filtered detailed report with rounded percentages", () => {
|
||||||
|
const result = buildChargeabilityReportDetail(
|
||||||
|
{
|
||||||
|
monthKeys: ["2026-03"],
|
||||||
|
groupTotals: [
|
||||||
|
{
|
||||||
|
monthKey: "2026-03",
|
||||||
|
totalFte: 1.234,
|
||||||
|
chg: 0.456,
|
||||||
|
target: 0.8,
|
||||||
|
gap: -0.344,
|
||||||
|
chargeabilityRatio: 0.456,
|
||||||
|
targetRatio: 0.8,
|
||||||
|
gapRatio: -0.344,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
resources: [
|
||||||
|
{
|
||||||
|
id: "resource_1",
|
||||||
|
eid: "E-001",
|
||||||
|
displayName: "Alice",
|
||||||
|
fte: 1.234,
|
||||||
|
country: "ES",
|
||||||
|
federalState: null,
|
||||||
|
city: "Barcelona",
|
||||||
|
orgUnit: "CGI",
|
||||||
|
mgmtGroup: "Senior",
|
||||||
|
mgmtLevel: "L7",
|
||||||
|
targetPct: 0.8,
|
||||||
|
months: [
|
||||||
|
{
|
||||||
|
monthKey: "2026-03",
|
||||||
|
sah: 120.44,
|
||||||
|
sahHours: 120.44,
|
||||||
|
chg: 0.456,
|
||||||
|
bd: 0.1,
|
||||||
|
mdi: 0.05,
|
||||||
|
mo: 0.07,
|
||||||
|
pdr: 0.08,
|
||||||
|
absence: 0.09,
|
||||||
|
unassigned: 0.154,
|
||||||
|
chargeabilityRatio: 0.456,
|
||||||
|
businessDevelopmentRatio: 0.1,
|
||||||
|
marketDevelopmentInnovationRatio: 0.05,
|
||||||
|
managementOverheadRatio: 0.07,
|
||||||
|
peopleDevelopmentRecruitingRatio: 0.08,
|
||||||
|
plannedAbsenceRatio: 0.09,
|
||||||
|
unassignedRatio: 0.154,
|
||||||
|
chargeabilityHours: 54.9,
|
||||||
|
businessDevelopmentHours: 12,
|
||||||
|
marketDevelopmentInnovationHours: 6,
|
||||||
|
managementOverheadHours: 8.4,
|
||||||
|
peopleDevelopmentRecruitingHours: 9.6,
|
||||||
|
plannedAbsenceHours: 10.8,
|
||||||
|
unassignedHours: 18.5,
|
||||||
|
targetRatio: 0.8,
|
||||||
|
targetHours: 96.4,
|
||||||
|
gapRatio: -0.344,
|
||||||
|
gapHours: -41.4,
|
||||||
|
derivation: {
|
||||||
|
baseAvailableHours: 128,
|
||||||
|
publicHolidayCount: 1,
|
||||||
|
publicHolidayWorkdayCount: 1,
|
||||||
|
publicHolidayHoursDeduction: 8,
|
||||||
|
absenceDayEquivalent: 0.5,
|
||||||
|
absenceHoursDeduction: 4,
|
||||||
|
effectiveAvailableHours: 120,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "resource_2",
|
||||||
|
eid: "E-002",
|
||||||
|
displayName: "Bob",
|
||||||
|
fte: 1,
|
||||||
|
country: "DE",
|
||||||
|
federalState: "BY",
|
||||||
|
city: "Munich",
|
||||||
|
orgUnit: "CGI",
|
||||||
|
mgmtGroup: "Senior",
|
||||||
|
mgmtLevel: "L7",
|
||||||
|
targetPct: 0.75,
|
||||||
|
months: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startMonth: "2026-03",
|
||||||
|
endMonth: "2026-03",
|
||||||
|
resourceQuery: "ali",
|
||||||
|
resourceLimit: 1,
|
||||||
|
includeProposed: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.filters).toEqual({
|
||||||
|
startMonth: "2026-03",
|
||||||
|
endMonth: "2026-03",
|
||||||
|
orgUnitId: null,
|
||||||
|
managementLevelGroupId: null,
|
||||||
|
countryId: null,
|
||||||
|
includeProposed: false,
|
||||||
|
resourceQuery: "ali",
|
||||||
|
});
|
||||||
|
expect(result.groupTotals).toEqual([
|
||||||
|
{
|
||||||
|
monthKey: "2026-03",
|
||||||
|
totalFte: 1.2,
|
||||||
|
chargeabilityPct: 45.6,
|
||||||
|
targetPct: 80,
|
||||||
|
gapPct: -34.4,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.resourceCount).toBe(1);
|
||||||
|
expect(result.returnedResourceCount).toBe(1);
|
||||||
|
expect(result.truncated).toBe(false);
|
||||||
|
expect(result.resources[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
displayName: "Alice",
|
||||||
|
targetPct: 80,
|
||||||
|
managementLevelGroup: "Senior",
|
||||||
|
managementLevel: "L7",
|
||||||
|
months: [
|
||||||
|
expect.objectContaining({
|
||||||
|
monthKey: "2026-03",
|
||||||
|
sah: 120.4,
|
||||||
|
chargeabilityPct: 45.6,
|
||||||
|
targetPct: 80,
|
||||||
|
gapPct: -34.4,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects detailed reports without the cost-view permission", async () => {
|
||||||
|
await expect(
|
||||||
|
getChargeabilityReportDetail(
|
||||||
|
{
|
||||||
|
db: {
|
||||||
|
resource: { findMany: async () => [] },
|
||||||
|
} as never,
|
||||||
|
permissions: new Set(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startMonth: "2026-03",
|
||||||
|
endMonth: "2026-03",
|
||||||
|
includeProposed: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).rejects.toEqual(
|
||||||
|
expect.objectContaining<Partial<TRPCError>>({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: `Permission required: ${PermissionKey.VIEW_COSTS}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
import {
|
||||||
|
deriveResourceForecast,
|
||||||
|
calculateGroupChargeability,
|
||||||
|
calculateGroupTarget,
|
||||||
|
sumFte,
|
||||||
|
getMonthRange,
|
||||||
|
getMonthKeys,
|
||||||
|
type AssignmentSlice,
|
||||||
|
} from "@capakraken/engine";
|
||||||
|
import type { PrismaClient } from "@capakraken/db";
|
||||||
|
import type { WeekdayAvailability, PermissionKey } from "@capakraken/shared";
|
||||||
|
import { PermissionKey as PermissionKeys } from "@capakraken/shared";
|
||||||
|
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||||
|
import {
|
||||||
|
calculateEffectiveAvailableHours,
|
||||||
|
calculateEffectiveBookedHours,
|
||||||
|
getAvailabilityHoursForDate,
|
||||||
|
loadResourceDailyAvailabilityContexts,
|
||||||
|
} from "../lib/resource-capacity.js";
|
||||||
|
import { requirePermission, type TRPCContext } from "../trpc.js";
|
||||||
|
|
||||||
|
type ChargeabilityReportProcedureContext = Pick<TRPCContext, "db"> & {
|
||||||
|
permissions?: Set<PermissionKey>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function round1(value: number): number {
|
||||||
|
return Math.round(value * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIsoDateInMonth(isoDate: string, monthKey: string): boolean {
|
||||||
|
return isoDate.startsWith(`${monthKey}-`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMonthCapacityDerivation(input: {
|
||||||
|
monthKey: string;
|
||||||
|
availability: WeekdayAvailability;
|
||||||
|
context: (Awaited<ReturnType<typeof loadResourceDailyAvailabilityContexts>> extends Map<string, infer T> ? T : never) | undefined;
|
||||||
|
baseAvailableHours: number;
|
||||||
|
effectiveAvailableHours: number;
|
||||||
|
}) {
|
||||||
|
const holidayDates = [...(input.context?.holidayDates ?? new Set<string>())]
|
||||||
|
.filter((isoDate) => isIsoDateInMonth(isoDate, input.monthKey))
|
||||||
|
.sort();
|
||||||
|
const publicHolidayWorkdayCount = holidayDates.reduce((count, isoDate) => (
|
||||||
|
count + (getAvailabilityHoursForDate(input.availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0)
|
||||||
|
), 0);
|
||||||
|
const publicHolidayHoursDeduction = holidayDates.reduce((sum, isoDate) => (
|
||||||
|
sum + getAvailabilityHoursForDate(input.availability, new Date(`${isoDate}T00:00:00.000Z`))
|
||||||
|
), 0);
|
||||||
|
|
||||||
|
let absenceDayEquivalent = 0;
|
||||||
|
let absenceHoursDeduction = 0;
|
||||||
|
for (const [isoDate, fraction] of input.context?.vacationFractionsByDate ?? []) {
|
||||||
|
if (!isIsoDateInMonth(isoDate, input.monthKey) || input.context?.holidayDates.has(isoDate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dayHours = getAvailabilityHoursForDate(input.availability, new Date(`${isoDate}T00:00:00.000Z`));
|
||||||
|
if (dayHours <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
absenceDayEquivalent += fraction;
|
||||||
|
absenceHoursDeduction += dayHours * fraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseAvailableHours: round1(input.baseAvailableHours),
|
||||||
|
publicHolidayCount: holidayDates.length,
|
||||||
|
publicHolidayWorkdayCount,
|
||||||
|
publicHolidayHoursDeduction: round1(publicHolidayHoursDeduction),
|
||||||
|
absenceDayEquivalent: round1(absenceDayEquivalent),
|
||||||
|
absenceHoursDeduction: round1(absenceHoursDeduction),
|
||||||
|
effectiveAvailableHours: round1(input.effectiveAvailableHours),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type MonthCapacityDerivation = ReturnType<typeof getMonthCapacityDerivation>;
|
||||||
|
type ForecastBreakdown = ReturnType<typeof deriveResourceForecast>;
|
||||||
|
|
||||||
|
function toHours(ratio: number, sahHours: number): number {
|
||||||
|
return round1(sahHours * ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReportMonth(input: {
|
||||||
|
monthKey: string;
|
||||||
|
sahHours: number;
|
||||||
|
targetRatio: number;
|
||||||
|
forecast: ForecastBreakdown;
|
||||||
|
derivation: MonthCapacityDerivation;
|
||||||
|
}) {
|
||||||
|
const { monthKey, sahHours, targetRatio, forecast, derivation } = input;
|
||||||
|
const chargeabilityRatio = forecast.chg;
|
||||||
|
const businessDevelopmentRatio = forecast.bd;
|
||||||
|
const marketDevelopmentInnovationRatio = forecast.mdi;
|
||||||
|
const managementOverheadRatio = forecast.mo;
|
||||||
|
const peopleDevelopmentRecruitingRatio = forecast.pdr;
|
||||||
|
const plannedAbsenceRatio = forecast.absence;
|
||||||
|
const unassignedRatio = forecast.unassigned;
|
||||||
|
const gapRatio = chargeabilityRatio - targetRatio;
|
||||||
|
|
||||||
|
return {
|
||||||
|
monthKey,
|
||||||
|
sah: round1(sahHours),
|
||||||
|
sahHours: round1(sahHours),
|
||||||
|
chg: chargeabilityRatio,
|
||||||
|
bd: businessDevelopmentRatio,
|
||||||
|
mdi: marketDevelopmentInnovationRatio,
|
||||||
|
mo: managementOverheadRatio,
|
||||||
|
pdr: peopleDevelopmentRecruitingRatio,
|
||||||
|
absence: plannedAbsenceRatio,
|
||||||
|
unassigned: unassignedRatio,
|
||||||
|
chargeabilityRatio,
|
||||||
|
businessDevelopmentRatio,
|
||||||
|
marketDevelopmentInnovationRatio,
|
||||||
|
managementOverheadRatio,
|
||||||
|
peopleDevelopmentRecruitingRatio,
|
||||||
|
plannedAbsenceRatio,
|
||||||
|
unassignedRatio,
|
||||||
|
chargeabilityHours: toHours(chargeabilityRatio, sahHours),
|
||||||
|
businessDevelopmentHours: toHours(businessDevelopmentRatio, sahHours),
|
||||||
|
marketDevelopmentInnovationHours: toHours(marketDevelopmentInnovationRatio, sahHours),
|
||||||
|
managementOverheadHours: toHours(managementOverheadRatio, sahHours),
|
||||||
|
peopleDevelopmentRecruitingHours: toHours(peopleDevelopmentRecruitingRatio, sahHours),
|
||||||
|
plannedAbsenceHours: toHours(plannedAbsenceRatio, sahHours),
|
||||||
|
unassignedHours: toHours(unassignedRatio, sahHours),
|
||||||
|
targetRatio,
|
||||||
|
targetHours: toHours(targetRatio, sahHours),
|
||||||
|
gapRatio,
|
||||||
|
gapHours: toHours(gapRatio, sahHours),
|
||||||
|
derivation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGroupTotal(input: {
|
||||||
|
monthKey: string;
|
||||||
|
totalFte: number;
|
||||||
|
chargeabilityRatio: number;
|
||||||
|
targetRatio: number;
|
||||||
|
}) {
|
||||||
|
const gapRatio = input.chargeabilityRatio - input.targetRatio;
|
||||||
|
|
||||||
|
return {
|
||||||
|
monthKey: input.monthKey,
|
||||||
|
totalFte: input.totalFte,
|
||||||
|
chg: input.chargeabilityRatio,
|
||||||
|
target: input.targetRatio,
|
||||||
|
gap: gapRatio,
|
||||||
|
chargeabilityRatio: input.chargeabilityRatio,
|
||||||
|
targetRatio: input.targetRatio,
|
||||||
|
gapRatio,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chargeabilityReportInputSchema = z.object({
|
||||||
|
startMonth: z.string().regex(/^\d{4}-\d{2}$/),
|
||||||
|
endMonth: z.string().regex(/^\d{4}-\d{2}$/),
|
||||||
|
orgUnitId: z.string().optional(),
|
||||||
|
managementLevelGroupId: z.string().optional(),
|
||||||
|
countryId: z.string().optional(),
|
||||||
|
includeProposed: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const chargeabilityReportDetailInputSchema = chargeabilityReportInputSchema.extend({
|
||||||
|
resourceQuery: z.string().optional(),
|
||||||
|
resourceLimit: z.number().int().min(1).max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ChargeabilityReportInput = z.infer<typeof chargeabilityReportInputSchema>;
|
||||||
|
type ChargeabilityReportDetailInput = z.infer<typeof chargeabilityReportDetailInputSchema>;
|
||||||
|
|
||||||
|
type ChargeabilityReportDbClient = Pick<
|
||||||
|
PrismaClient,
|
||||||
|
"assignment" | "resource" | "project" | "vacation" | "holidayCalendar" | "systemSettings"
|
||||||
|
>;
|
||||||
|
|
||||||
|
async function queryChargeabilityReport(
|
||||||
|
db: ChargeabilityReportDbClient,
|
||||||
|
input: ChargeabilityReportInput,
|
||||||
|
) {
|
||||||
|
const { startMonth, endMonth, includeProposed } = input;
|
||||||
|
|
||||||
|
const [startYear, startMo] = startMonth.split("-").map(Number) as [number, number];
|
||||||
|
const [endYear, endMo] = endMonth.split("-").map(Number) as [number, number];
|
||||||
|
const rangeStart = getMonthRange(startYear, startMo).start;
|
||||||
|
const rangeEnd = getMonthRange(endYear, endMo).end;
|
||||||
|
const monthKeys = getMonthKeys(rangeStart, rangeEnd);
|
||||||
|
|
||||||
|
const resourceWhere = {
|
||||||
|
isActive: true,
|
||||||
|
chgResponsibility: true,
|
||||||
|
departed: false,
|
||||||
|
rolledOff: false,
|
||||||
|
...(input.orgUnitId ? { orgUnitId: input.orgUnitId } : {}),
|
||||||
|
...(input.managementLevelGroupId ? { managementLevelGroupId: input.managementLevelGroupId } : {}),
|
||||||
|
...(input.countryId ? { countryId: input.countryId } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const resources = await db.resource.findMany({
|
||||||
|
where: resourceWhere,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
eid: true,
|
||||||
|
displayName: true,
|
||||||
|
fte: true,
|
||||||
|
availability: true,
|
||||||
|
countryId: true,
|
||||||
|
federalState: true,
|
||||||
|
metroCityId: true,
|
||||||
|
chargeabilityTarget: true,
|
||||||
|
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
|
||||||
|
orgUnit: { select: { id: true, name: true } },
|
||||||
|
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
|
||||||
|
managementLevel: { select: { id: true, name: true } },
|
||||||
|
metroCity: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { displayName: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resources.length === 0) {
|
||||||
|
return {
|
||||||
|
monthKeys,
|
||||||
|
resources: [],
|
||||||
|
groupTotals: monthKeys.map((key) => buildGroupTotal({
|
||||||
|
monthKey: key,
|
||||||
|
totalFte: 0,
|
||||||
|
chargeabilityRatio: 0,
|
||||||
|
targetRatio: 0,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceIds = resources.map((resource) => resource.id);
|
||||||
|
const allBookings = await listAssignmentBookings(db, {
|
||||||
|
startDate: rangeStart,
|
||||||
|
endDate: rangeEnd,
|
||||||
|
resourceIds,
|
||||||
|
});
|
||||||
|
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
rangeStart,
|
||||||
|
rangeEnd,
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectIds = [...new Set(allBookings.map((booking) => booking.projectId))];
|
||||||
|
const projectUtilCats = projectIds.length > 0
|
||||||
|
? await db.project.findMany({
|
||||||
|
where: { id: { in: projectIds } },
|
||||||
|
select: { id: true, utilizationCategory: { select: { code: true } } },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const projectUtilCatMap = new Map(
|
||||||
|
projectUtilCats.map((project) => [project.id, project.utilizationCategory?.code ?? null]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const assignments = allBookings
|
||||||
|
.filter((booking) => booking.resourceId !== null)
|
||||||
|
.filter((booking) => isChargeabilityActualBooking(booking, includeProposed))
|
||||||
|
.map((booking) => ({
|
||||||
|
resourceId: booking.resourceId!,
|
||||||
|
startDate: booking.startDate,
|
||||||
|
endDate: booking.endDate,
|
||||||
|
hoursPerDay: booking.hoursPerDay,
|
||||||
|
project: {
|
||||||
|
status: booking.project.status,
|
||||||
|
utilizationCategory: { code: projectUtilCatMap.get(booking.projectId) ?? null },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const resourceRows = await Promise.all(resources.map(async (resource) => {
|
||||||
|
const resourceAssignments = assignments.filter((assignment) => assignment.resourceId === resource.id);
|
||||||
|
const targetPct = resource.managementLevelGroup?.targetPercentage
|
||||||
|
?? (resource.chargeabilityTarget / 100);
|
||||||
|
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||||
|
const context = availabilityContexts.get(resource.id);
|
||||||
|
|
||||||
|
const months = await Promise.all(monthKeys.map(async (key) => {
|
||||||
|
const [year, month] = key.split("-").map(Number) as [number, number];
|
||||||
|
const { start: monthStart, end: monthEnd } = getMonthRange(year, month);
|
||||||
|
const baseAvailableHours = calculateEffectiveAvailableHours({
|
||||||
|
availability,
|
||||||
|
periodStart: monthStart,
|
||||||
|
periodEnd: monthEnd,
|
||||||
|
context: undefined,
|
||||||
|
});
|
||||||
|
const availableHours = calculateEffectiveAvailableHours({
|
||||||
|
availability,
|
||||||
|
periodStart: monthStart,
|
||||||
|
periodEnd: monthEnd,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
const slices: AssignmentSlice[] = resourceAssignments.flatMap((assignment) => {
|
||||||
|
const totalChargeableHours = calculateEffectiveBookedHours({
|
||||||
|
availability,
|
||||||
|
startDate: assignment.startDate,
|
||||||
|
endDate: assignment.endDate,
|
||||||
|
hoursPerDay: assignment.hoursPerDay,
|
||||||
|
periodStart: monthStart,
|
||||||
|
periodEnd: monthEnd,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
if (totalChargeableHours <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryCode = assignment.project.utilizationCategory?.code;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hoursPerDay: assignment.hoursPerDay,
|
||||||
|
workingDays: 0,
|
||||||
|
categoryCode: typeof categoryCode === "string" && categoryCode.length > 0 ? categoryCode : "Chg",
|
||||||
|
totalChargeableHours,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const forecast = deriveResourceForecast({
|
||||||
|
fte: resource.fte,
|
||||||
|
targetPercentage: targetPct,
|
||||||
|
assignments: slices,
|
||||||
|
sah: availableHours,
|
||||||
|
});
|
||||||
|
const derivation = getMonthCapacityDerivation({
|
||||||
|
monthKey: key,
|
||||||
|
availability,
|
||||||
|
context,
|
||||||
|
baseAvailableHours,
|
||||||
|
effectiveAvailableHours: availableHours,
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildReportMonth({
|
||||||
|
monthKey: key,
|
||||||
|
sahHours: availableHours,
|
||||||
|
targetRatio: targetPct,
|
||||||
|
forecast,
|
||||||
|
derivation,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: resource.id,
|
||||||
|
eid: resource.eid,
|
||||||
|
displayName: resource.displayName,
|
||||||
|
fte: resource.fte,
|
||||||
|
country: resource.country?.code ?? null,
|
||||||
|
federalState: resource.federalState ?? null,
|
||||||
|
city: resource.metroCity?.name ?? null,
|
||||||
|
orgUnit: resource.orgUnit?.name ?? null,
|
||||||
|
mgmtGroup: resource.managementLevelGroup?.name ?? null,
|
||||||
|
mgmtLevel: resource.managementLevel?.name ?? null,
|
||||||
|
targetPct,
|
||||||
|
months,
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
const groupTotals = monthKeys.map((key, monthIdx) => {
|
||||||
|
const groupInputs = resourceRows.map((resource) => ({
|
||||||
|
fte: resource.fte,
|
||||||
|
chargeability: resource.months[monthIdx]!.chg,
|
||||||
|
}));
|
||||||
|
const targetInputs = resourceRows.map((resource) => ({
|
||||||
|
fte: resource.fte,
|
||||||
|
targetPercentage: resource.targetPct,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const chg = calculateGroupChargeability(groupInputs);
|
||||||
|
const target = calculateGroupTarget(targetInputs);
|
||||||
|
|
||||||
|
return buildGroupTotal({
|
||||||
|
monthKey: key,
|
||||||
|
totalFte: sumFte(resourceRows),
|
||||||
|
chargeabilityRatio: chg,
|
||||||
|
targetRatio: target,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const directory = await getAnonymizationDirectory(db);
|
||||||
|
|
||||||
|
return {
|
||||||
|
monthKeys,
|
||||||
|
resources: anonymizeResources(resourceRows, directory),
|
||||||
|
groupTotals,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildChargeabilityReportDetail(
|
||||||
|
report: Awaited<ReturnType<typeof queryChargeabilityReport>>,
|
||||||
|
input: ChargeabilityReportDetailInput,
|
||||||
|
) {
|
||||||
|
const resourceQuery = input.resourceQuery?.trim().toLowerCase();
|
||||||
|
const matchingResources = resourceQuery
|
||||||
|
? report.resources.filter((resource) => (
|
||||||
|
resource.displayName.toLowerCase().includes(resourceQuery)
|
||||||
|
|| resource.eid.toLowerCase().includes(resourceQuery)
|
||||||
|
))
|
||||||
|
: report.resources;
|
||||||
|
const resourceLimit = Math.min(Math.max(input.resourceLimit ?? 25, 1), 100);
|
||||||
|
const resources = matchingResources.slice(0, resourceLimit).map((resource) => ({
|
||||||
|
id: resource.id,
|
||||||
|
eid: resource.eid,
|
||||||
|
displayName: resource.displayName,
|
||||||
|
fte: round1(resource.fte),
|
||||||
|
country: resource.country,
|
||||||
|
federalState: resource.federalState ?? null,
|
||||||
|
city: resource.city,
|
||||||
|
orgUnit: resource.orgUnit,
|
||||||
|
managementLevelGroup: resource.mgmtGroup,
|
||||||
|
managementLevel: resource.mgmtLevel,
|
||||||
|
targetPct: round1(resource.targetPct * 100),
|
||||||
|
months: resource.months.map((month) => ({
|
||||||
|
monthKey: month.monthKey,
|
||||||
|
sah: round1(month.sah),
|
||||||
|
sahHours: round1(month.sahHours),
|
||||||
|
chargeabilityPct: round1(month.chg * 100),
|
||||||
|
chargeabilityHours: month.chargeabilityHours,
|
||||||
|
targetPct: round1(resource.targetPct * 100),
|
||||||
|
targetHours: month.targetHours,
|
||||||
|
gapPct: round1((month.chg - resource.targetPct) * 100),
|
||||||
|
gapHours: month.gapHours,
|
||||||
|
businessDevelopmentPct: round1(month.businessDevelopmentRatio * 100),
|
||||||
|
businessDevelopmentHours: month.businessDevelopmentHours,
|
||||||
|
marketDevelopmentInnovationPct: round1(month.marketDevelopmentInnovationRatio * 100),
|
||||||
|
marketDevelopmentInnovationHours: month.marketDevelopmentInnovationHours,
|
||||||
|
managementOverheadPct: round1(month.managementOverheadRatio * 100),
|
||||||
|
managementOverheadHours: month.managementOverheadHours,
|
||||||
|
peopleDevelopmentRecruitingPct: round1(month.peopleDevelopmentRecruitingRatio * 100),
|
||||||
|
peopleDevelopmentRecruitingHours: month.peopleDevelopmentRecruitingHours,
|
||||||
|
plannedAbsencePct: round1(month.plannedAbsenceRatio * 100),
|
||||||
|
plannedAbsenceHours: month.plannedAbsenceHours,
|
||||||
|
unassignedPct: round1(month.unassignedRatio * 100),
|
||||||
|
unassignedHours: month.unassignedHours,
|
||||||
|
derivation: month.derivation,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
filters: {
|
||||||
|
startMonth: input.startMonth,
|
||||||
|
endMonth: input.endMonth,
|
||||||
|
orgUnitId: input.orgUnitId ?? null,
|
||||||
|
managementLevelGroupId: input.managementLevelGroupId ?? null,
|
||||||
|
countryId: input.countryId ?? null,
|
||||||
|
includeProposed: input.includeProposed ?? false,
|
||||||
|
resourceQuery: input.resourceQuery ?? null,
|
||||||
|
},
|
||||||
|
monthKeys: report.monthKeys,
|
||||||
|
groupTotals: report.groupTotals.map((group) => ({
|
||||||
|
monthKey: group.monthKey,
|
||||||
|
totalFte: round1(group.totalFte),
|
||||||
|
chargeabilityPct: round1(group.chg * 100),
|
||||||
|
targetPct: round1(group.target * 100),
|
||||||
|
gapPct: round1(group.gap * 100),
|
||||||
|
})),
|
||||||
|
resourceCount: matchingResources.length,
|
||||||
|
returnedResourceCount: resources.length,
|
||||||
|
truncated: resources.length < matchingResources.length,
|
||||||
|
resources,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChargeabilityReport(
|
||||||
|
ctx: ChargeabilityReportProcedureContext,
|
||||||
|
input: ChargeabilityReportInput,
|
||||||
|
) {
|
||||||
|
return queryChargeabilityReport(ctx.db, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChargeabilityReportDetail(
|
||||||
|
ctx: ChargeabilityReportProcedureContext,
|
||||||
|
input: ChargeabilityReportDetailInput,
|
||||||
|
) {
|
||||||
|
requirePermission(
|
||||||
|
{ permissions: ctx.permissions ?? new Set<PermissionKey>() },
|
||||||
|
PermissionKeys.VIEW_COSTS,
|
||||||
|
);
|
||||||
|
const report = await queryChargeabilityReport(ctx.db, input);
|
||||||
|
return buildChargeabilityReportDetail(report, input);
|
||||||
|
}
|
||||||
@@ -1,318 +1,17 @@
|
|||||||
|
import { controllerProcedure, createTRPCRouter } from "../trpc.js";
|
||||||
import {
|
import {
|
||||||
deriveResourceForecast,
|
chargeabilityReportDetailInputSchema,
|
||||||
calculateGroupChargeability,
|
chargeabilityReportInputSchema,
|
||||||
calculateGroupTarget,
|
getChargeabilityReport,
|
||||||
sumFte,
|
getChargeabilityReportDetail,
|
||||||
getMonthRange,
|
} from "./chargeability-report-procedure-support.js";
|
||||||
getMonthKeys,
|
|
||||||
type AssignmentSlice,
|
|
||||||
} from "@capakraken/engine";
|
|
||||||
import type { PrismaClient } from "@capakraken/db";
|
|
||||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
|
||||||
import { PermissionKey } from "@capakraken/shared";
|
|
||||||
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { createTRPCRouter, controllerProcedure, requirePermission } from "../trpc.js";
|
|
||||||
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
|
|
||||||
import {
|
|
||||||
calculateEffectiveAvailableHours,
|
|
||||||
calculateEffectiveBookedHours,
|
|
||||||
loadResourceDailyAvailabilityContexts,
|
|
||||||
} from "../lib/resource-capacity.js";
|
|
||||||
|
|
||||||
function round1(value: number): number {
|
|
||||||
return Math.round(value * 10) / 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reportInputSchema = z.object({
|
|
||||||
startMonth: z.string().regex(/^\d{4}-\d{2}$/),
|
|
||||||
endMonth: z.string().regex(/^\d{4}-\d{2}$/),
|
|
||||||
orgUnitId: z.string().optional(),
|
|
||||||
managementLevelGroupId: z.string().optional(),
|
|
||||||
countryId: z.string().optional(),
|
|
||||||
includeProposed: z.boolean().default(false),
|
|
||||||
});
|
|
||||||
|
|
||||||
const detailedReportInputSchema = reportInputSchema.extend({
|
|
||||||
resourceQuery: z.string().optional(),
|
|
||||||
resourceLimit: z.number().int().min(1).max(100).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type ChargeabilityReportDbClient = Pick<
|
|
||||||
PrismaClient,
|
|
||||||
"assignment" | "resource" | "project" | "vacation" | "holidayCalendar" | "systemSettings"
|
|
||||||
>;
|
|
||||||
|
|
||||||
async function queryChargeabilityReport(
|
|
||||||
db: ChargeabilityReportDbClient,
|
|
||||||
input: z.infer<typeof reportInputSchema>,
|
|
||||||
) {
|
|
||||||
const { startMonth, endMonth, includeProposed } = input;
|
|
||||||
|
|
||||||
const [startYear, startMo] = startMonth.split("-").map(Number) as [number, number];
|
|
||||||
const [endYear, endMo] = endMonth.split("-").map(Number) as [number, number];
|
|
||||||
const rangeStart = getMonthRange(startYear, startMo).start;
|
|
||||||
const rangeEnd = getMonthRange(endYear, endMo).end;
|
|
||||||
const monthKeys = getMonthKeys(rangeStart, rangeEnd);
|
|
||||||
|
|
||||||
const resourceWhere = {
|
|
||||||
isActive: true,
|
|
||||||
chgResponsibility: true,
|
|
||||||
departed: false,
|
|
||||||
rolledOff: false,
|
|
||||||
...(input.orgUnitId ? { orgUnitId: input.orgUnitId } : {}),
|
|
||||||
...(input.managementLevelGroupId ? { managementLevelGroupId: input.managementLevelGroupId } : {}),
|
|
||||||
...(input.countryId ? { countryId: input.countryId } : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const resources = await db.resource.findMany({
|
|
||||||
where: resourceWhere,
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
eid: true,
|
|
||||||
displayName: true,
|
|
||||||
fte: true,
|
|
||||||
availability: true,
|
|
||||||
countryId: true,
|
|
||||||
federalState: true,
|
|
||||||
metroCityId: true,
|
|
||||||
chargeabilityTarget: true,
|
|
||||||
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
|
|
||||||
orgUnit: { select: { id: true, name: true } },
|
|
||||||
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
|
|
||||||
managementLevel: { select: { id: true, name: true } },
|
|
||||||
metroCity: { select: { id: true, name: true } },
|
|
||||||
},
|
|
||||||
orderBy: { displayName: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (resources.length === 0) {
|
|
||||||
return {
|
|
||||||
monthKeys,
|
|
||||||
resources: [],
|
|
||||||
groupTotals: monthKeys.map((key) => ({
|
|
||||||
monthKey: key,
|
|
||||||
totalFte: 0,
|
|
||||||
chg: 0,
|
|
||||||
target: 0,
|
|
||||||
gap: 0,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const resourceIds = resources.map((resource) => resource.id);
|
|
||||||
const allBookings = await listAssignmentBookings(db, {
|
|
||||||
startDate: rangeStart,
|
|
||||||
endDate: rangeEnd,
|
|
||||||
resourceIds,
|
|
||||||
});
|
|
||||||
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
|
|
||||||
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,
|
|
||||||
})),
|
|
||||||
rangeStart,
|
|
||||||
rangeEnd,
|
|
||||||
);
|
|
||||||
|
|
||||||
const projectIds = [...new Set(allBookings.map((booking) => booking.projectId))];
|
|
||||||
const projectUtilCats = projectIds.length > 0
|
|
||||||
? await db.project.findMany({
|
|
||||||
where: { id: { in: projectIds } },
|
|
||||||
select: { id: true, utilizationCategory: { select: { code: true } } },
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
const projectUtilCatMap = new Map(
|
|
||||||
projectUtilCats.map((project) => [project.id, project.utilizationCategory?.code ?? null]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const assignments = allBookings
|
|
||||||
.filter((booking) => booking.resourceId !== null)
|
|
||||||
.filter((booking) => isChargeabilityActualBooking(booking, includeProposed))
|
|
||||||
.map((booking) => ({
|
|
||||||
resourceId: booking.resourceId!,
|
|
||||||
startDate: booking.startDate,
|
|
||||||
endDate: booking.endDate,
|
|
||||||
hoursPerDay: booking.hoursPerDay,
|
|
||||||
project: {
|
|
||||||
status: booking.project.status,
|
|
||||||
utilizationCategory: { code: projectUtilCatMap.get(booking.projectId) ?? null },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const resourceRows = await Promise.all(resources.map(async (resource) => {
|
|
||||||
const resourceAssignments = assignments.filter((assignment) => assignment.resourceId === resource.id);
|
|
||||||
const targetPct = resource.managementLevelGroup?.targetPercentage
|
|
||||||
?? (resource.chargeabilityTarget / 100);
|
|
||||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
|
||||||
const context = availabilityContexts.get(resource.id);
|
|
||||||
|
|
||||||
const months = await Promise.all(monthKeys.map(async (key) => {
|
|
||||||
const [year, month] = key.split("-").map(Number) as [number, number];
|
|
||||||
const { start: monthStart, end: monthEnd } = getMonthRange(year, month);
|
|
||||||
const availableHours = calculateEffectiveAvailableHours({
|
|
||||||
availability,
|
|
||||||
periodStart: monthStart,
|
|
||||||
periodEnd: monthEnd,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
const slices: AssignmentSlice[] = resourceAssignments.flatMap((assignment) => {
|
|
||||||
const totalChargeableHours = calculateEffectiveBookedHours({
|
|
||||||
availability,
|
|
||||||
startDate: assignment.startDate,
|
|
||||||
endDate: assignment.endDate,
|
|
||||||
hoursPerDay: assignment.hoursPerDay,
|
|
||||||
periodStart: monthStart,
|
|
||||||
periodEnd: monthEnd,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
if (totalChargeableHours <= 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoryCode = assignment.project.utilizationCategory?.code;
|
|
||||||
|
|
||||||
return {
|
|
||||||
hoursPerDay: assignment.hoursPerDay,
|
|
||||||
workingDays: 0,
|
|
||||||
categoryCode: typeof categoryCode === "string" && categoryCode.length > 0 ? categoryCode : "Chg",
|
|
||||||
totalChargeableHours,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const forecast = deriveResourceForecast({
|
|
||||||
fte: resource.fte,
|
|
||||||
targetPercentage: targetPct,
|
|
||||||
assignments: slices,
|
|
||||||
sah: availableHours,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
monthKey: key,
|
|
||||||
sah: availableHours,
|
|
||||||
...forecast,
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: resource.id,
|
|
||||||
eid: resource.eid,
|
|
||||||
displayName: resource.displayName,
|
|
||||||
fte: resource.fte,
|
|
||||||
country: resource.country?.code ?? null,
|
|
||||||
city: resource.metroCity?.name ?? null,
|
|
||||||
orgUnit: resource.orgUnit?.name ?? null,
|
|
||||||
mgmtGroup: resource.managementLevelGroup?.name ?? null,
|
|
||||||
mgmtLevel: resource.managementLevel?.name ?? null,
|
|
||||||
targetPct,
|
|
||||||
months,
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
|
|
||||||
const groupTotals = monthKeys.map((key, monthIdx) => {
|
|
||||||
const groupInputs = resourceRows.map((resource) => ({
|
|
||||||
fte: resource.fte,
|
|
||||||
chargeability: resource.months[monthIdx]!.chg,
|
|
||||||
}));
|
|
||||||
const targetInputs = resourceRows.map((resource) => ({
|
|
||||||
fte: resource.fte,
|
|
||||||
targetPercentage: resource.targetPct,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const chg = calculateGroupChargeability(groupInputs);
|
|
||||||
const target = calculateGroupTarget(targetInputs);
|
|
||||||
|
|
||||||
return {
|
|
||||||
monthKey: key,
|
|
||||||
totalFte: sumFte(resourceRows),
|
|
||||||
chg,
|
|
||||||
target,
|
|
||||||
gap: chg - target,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const directory = await getAnonymizationDirectory(db);
|
|
||||||
|
|
||||||
return {
|
|
||||||
monthKeys,
|
|
||||||
resources: anonymizeResources(resourceRows, directory),
|
|
||||||
groupTotals,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildChargeabilityReportDetail(
|
|
||||||
report: Awaited<ReturnType<typeof queryChargeabilityReport>>,
|
|
||||||
input: z.infer<typeof detailedReportInputSchema>,
|
|
||||||
) {
|
|
||||||
const resourceQuery = input.resourceQuery?.trim().toLowerCase();
|
|
||||||
const matchingResources = resourceQuery
|
|
||||||
? report.resources.filter((resource) => (
|
|
||||||
resource.displayName.toLowerCase().includes(resourceQuery)
|
|
||||||
|| resource.eid.toLowerCase().includes(resourceQuery)
|
|
||||||
))
|
|
||||||
: report.resources;
|
|
||||||
const resourceLimit = Math.min(Math.max(input.resourceLimit ?? 25, 1), 100);
|
|
||||||
const resources = matchingResources.slice(0, resourceLimit).map((resource) => ({
|
|
||||||
id: resource.id,
|
|
||||||
eid: resource.eid,
|
|
||||||
displayName: resource.displayName,
|
|
||||||
fte: round1(resource.fte),
|
|
||||||
country: resource.country,
|
|
||||||
city: resource.city,
|
|
||||||
orgUnit: resource.orgUnit,
|
|
||||||
managementLevelGroup: resource.mgmtGroup,
|
|
||||||
managementLevel: resource.mgmtLevel,
|
|
||||||
targetPct: round1(resource.targetPct * 100),
|
|
||||||
months: resource.months.map((month) => ({
|
|
||||||
monthKey: month.monthKey,
|
|
||||||
sah: round1(month.sah),
|
|
||||||
chargeabilityPct: round1(month.chg * 100),
|
|
||||||
targetPct: round1(resource.targetPct * 100),
|
|
||||||
gapPct: round1((month.chg - resource.targetPct) * 100),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
filters: {
|
|
||||||
startMonth: input.startMonth,
|
|
||||||
endMonth: input.endMonth,
|
|
||||||
orgUnitId: input.orgUnitId ?? null,
|
|
||||||
managementLevelGroupId: input.managementLevelGroupId ?? null,
|
|
||||||
countryId: input.countryId ?? null,
|
|
||||||
includeProposed: input.includeProposed ?? false,
|
|
||||||
resourceQuery: input.resourceQuery ?? null,
|
|
||||||
},
|
|
||||||
monthKeys: report.monthKeys,
|
|
||||||
groupTotals: report.groupTotals.map((group) => ({
|
|
||||||
monthKey: group.monthKey,
|
|
||||||
totalFte: round1(group.totalFte),
|
|
||||||
chargeabilityPct: round1(group.chg * 100),
|
|
||||||
targetPct: round1(group.target * 100),
|
|
||||||
gapPct: round1(group.gap * 100),
|
|
||||||
})),
|
|
||||||
resourceCount: matchingResources.length,
|
|
||||||
returnedResourceCount: resources.length,
|
|
||||||
truncated: resources.length < matchingResources.length,
|
|
||||||
resources,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const chargeabilityReportRouter = createTRPCRouter({
|
export const chargeabilityReportRouter = createTRPCRouter({
|
||||||
getReport: controllerProcedure
|
getReport: controllerProcedure
|
||||||
.input(reportInputSchema)
|
.input(chargeabilityReportInputSchema)
|
||||||
.query(async ({ ctx, input }) => queryChargeabilityReport(ctx.db, input)),
|
.query(({ ctx, input }) => getChargeabilityReport(ctx, input)),
|
||||||
|
|
||||||
getDetail: controllerProcedure
|
getDetail: controllerProcedure
|
||||||
.input(detailedReportInputSchema)
|
.input(chargeabilityReportDetailInputSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(({ ctx, input }) => getChargeabilityReportDetail(ctx, input)),
|
||||||
requirePermission(ctx, PermissionKey.VIEW_COSTS);
|
|
||||||
const report = await queryChargeabilityReport(ctx.db, input);
|
|
||||||
return buildChargeabilityReportDetail(report, input);
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user