diff --git a/packages/api/src/__tests__/chargeability-report-router.test.ts b/packages/api/src/__tests__/chargeability-report-router.test.ts index 7e28888..03d75a5 100644 --- a/packages/api/src/__tests__/chargeability-report-router.test.ts +++ b/packages/api/src/__tests__/chargeability-report-router.test.ts @@ -132,6 +132,38 @@ describe("chargeability report router", () => { expect(strictMonth).toBeDefined(); expect(proposedMonth).toBeDefined(); + expect(strict.explainability).toEqual({ + locationFields: [ + "country", + "federalState", + "city", + "orgUnit", + "managementLevelGroup", + "managementLevel", + ], + monthDerivationFields: [ + "baseAvailableHours", + "publicHolidayCount", + "publicHolidayWorkdayCount", + "publicHolidayHoursDeduction", + "absenceDayEquivalent", + "absenceHoursDeduction", + "effectiveAvailableHours", + ], + activeFilters: [], + formulas: { + sah: "baseAvailableHours - publicHolidayHoursDeduction - absenceHoursDeduction = effectiveAvailableHours", + chargeabilityPct: "chargeabilityHours / sahHours", + targetHours: "sahHours * targetPct", + gapHours: "chargeabilityHours - targetHours", + }, + notes: [ + "Location fields explain why two resources can have different SAH in the same month because country, federal state, and city holidays may differ.", + "Holiday deductions and absence deductions are tracked separately; absence does not deduct days that are already public holidays.", + "Include proposed work changes chargeability ratios and hours, but it does not change holiday or absence-based SAH derivation.", + ], + }); + expect(withProposed.explainability.activeFilters).toEqual(["includeProposed"]); expect(strictMonth?.chg).toBeGreaterThan(0); expect(proposedMonth?.chg).toBeGreaterThan(strictMonth?.chg ?? 0); expect(proposedMonth?.chg).toBeCloseTo((strictMonth?.chg ?? 0) * 2, 5); @@ -352,6 +384,13 @@ describe("chargeability report router", () => { const augsburg = report.resources.find((resource) => resource.city === "Augsburg"); const munich = report.resources.find((resource) => resource.city === "Munich"); + expect(augsburg?.federalState).toBe("BY"); + expect(augsburg?.months[0]?.derivation?.publicHolidayCount).toBe( + (munich?.months[0]?.derivation?.publicHolidayCount ?? 0) + 1, + ); + expect(augsburg?.months[0]?.derivation?.publicHolidayHoursDeduction).toBe( + (munich?.months[0]?.derivation?.publicHolidayHoursDeduction ?? 0) + 8, + ); expect(augsburg?.months[0]?.sah).toBe((munich?.months[0]?.sah ?? 0) - 8); }); @@ -513,11 +552,43 @@ describe("chargeability report router", () => { expect(result.resourceCount).toBe(1); expect(result.returnedResourceCount).toBe(1); expect(result.truncated).toBe(false); + expect(result.explainability).toEqual({ + locationFields: [ + "country", + "federalState", + "city", + "orgUnit", + "managementLevelGroup", + "managementLevel", + ], + monthDerivationFields: [ + "baseAvailableHours", + "publicHolidayCount", + "publicHolidayWorkdayCount", + "publicHolidayHoursDeduction", + "absenceDayEquivalent", + "absenceHoursDeduction", + "effectiveAvailableHours", + ], + activeFilters: ["resourceQuery"], + formulas: { + sah: "baseAvailableHours - publicHolidayHoursDeduction - absenceHoursDeduction = effectiveAvailableHours", + chargeabilityPct: "chargeabilityHours / sahHours", + targetHours: "sahHours * targetPct", + gapHours: "chargeabilityHours - targetHours", + }, + notes: [ + "Location fields explain why two resources can have different SAH in the same month because country, federal state, and city holidays may differ.", + "Holiday deductions and absence deductions are tracked separately; absence does not deduct days that are already public holidays.", + "Include proposed work changes chargeability ratios and hours, but it does not change holiday or absence-based SAH derivation.", + ], + }); expect(result.resources).toEqual([ expect.objectContaining({ displayName: "Alice", targetPct: 80, country: "ES", + federalState: null, city: "Barcelona", managementLevelGroup: "Senior", managementLevel: "L7", @@ -527,6 +598,10 @@ describe("chargeability report router", () => { sah: expect.any(Number), chargeabilityPct: expect.any(Number), gapPct: expect.any(Number), + derivation: expect.objectContaining({ + baseAvailableHours: expect.any(Number), + effectiveAvailableHours: expect.any(Number), + }), }), ], }), diff --git a/packages/api/src/router/chargeability-report-procedure-support.ts b/packages/api/src/router/chargeability-report-procedure-support.ts index acee810..5c4a212 100644 --- a/packages/api/src/router/chargeability-report-procedure-support.ts +++ b/packages/api/src/router/chargeability-report-procedure-support.ts @@ -168,12 +168,62 @@ export const chargeabilityReportDetailInputSchema = chargeabilityReportInputSche type ChargeabilityReportInput = z.infer; type ChargeabilityReportDetailInput = z.infer; +type ChargeabilityExplainabilityInput = ChargeabilityReportInput & { + resourceQuery?: string | undefined; + resourceLimit?: number | undefined; +}; type ChargeabilityReportDbClient = Pick< PrismaClient, "assignment" | "resource" | "project" | "vacation" | "holidayCalendar" | "systemSettings" >; +const CHARGEABILITY_LOCATION_FIELDS = [ + "country", + "federalState", + "city", + "orgUnit", + "managementLevelGroup", + "managementLevel", +] as const; + +const CHARGEABILITY_DERIVATION_FIELDS = [ + "baseAvailableHours", + "publicHolidayCount", + "publicHolidayWorkdayCount", + "publicHolidayHoursDeduction", + "absenceDayEquivalent", + "absenceHoursDeduction", + "effectiveAvailableHours", +] as const; + +function buildChargeabilityExplainability(input: ChargeabilityExplainabilityInput) { + const activeFilters = [ + ...(input.orgUnitId ? ["orgUnitId"] : []), + ...(input.managementLevelGroupId ? ["managementLevelGroupId"] : []), + ...(input.countryId ? ["countryId"] : []), + ...(input.resourceQuery ? ["resourceQuery"] : []), + ...(input.includeProposed ? ["includeProposed"] : []), + ]; + + return { + locationFields: [...CHARGEABILITY_LOCATION_FIELDS], + monthDerivationFields: [...CHARGEABILITY_DERIVATION_FIELDS], + activeFilters, + formulas: { + sah: "baseAvailableHours - publicHolidayHoursDeduction - absenceHoursDeduction = effectiveAvailableHours", + chargeabilityPct: "chargeabilityHours / sahHours", + targetHours: "sahHours * targetPct", + gapHours: "chargeabilityHours - targetHours", + }, + notes: [ + "Location fields explain why two resources can have different SAH in the same month because country, federal state, and city holidays may differ.", + "Holiday deductions and absence deductions are tracked separately; absence does not deduct days that are already public holidays.", + "Include proposed work changes chargeability ratios and hours, but it does not change holiday or absence-based SAH derivation.", + ], + }; +} + async function queryChargeabilityReport( db: ChargeabilityReportDbClient, input: ChargeabilityReportInput, @@ -227,6 +277,7 @@ async function queryChargeabilityReport( chargeabilityRatio: 0, targetRatio: 0, })), + explainability: buildChargeabilityExplainability(input), }; } @@ -388,6 +439,7 @@ async function queryChargeabilityReport( monthKeys, resources: anonymizeResources(resourceRows, directory), groupTotals, + explainability: buildChargeabilityExplainability(input), }; } @@ -462,6 +514,7 @@ export function buildChargeabilityReportDetail( resourceCount: matchingResources.length, returnedResourceCount: resources.length, truncated: resources.length < matchingResources.length, + explainability: buildChargeabilityExplainability(input), resources, }; }