From 5a4836d2926fffe80c7f8f4cf0759f6ca0a8ea2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sat, 11 Apr 2026 22:59:45 +0200 Subject: [PATCH] perf(api): eliminate 3 N+1 query patterns - timeline-holiday-load-support: deduplicate getResolvedCalendarHolidays by location key so resources sharing the same country/state/city resolve holidays once instead of per-resource - rate-card-lookup: add lookupRatesBatch that loads rate card lines once and scores locally per demand line, replacing per-line DB round-trips in estimate-demand-lines autoFillDemandLineRates - config-readmodels: include _count in utilization-category list query instead of calling getById per category for project counts Co-Authored-By: Claude Opus 4.6 --- ...r-data-management-utilization-read.test.ts | 13 +- .../__tests__/master-data-router-auth.test.ts | 521 +++++++++++------- .../utilization-category-router.test.ts | 1 + packages/api/src/lib/rate-card-lookup.ts | 261 +++++---- .../assistant-tools/config-readmodels.ts | 323 +++++------ .../api/src/router/estimate-demand-lines.ts | 99 ++-- .../router/timeline-holiday-load-support.ts | 71 ++- .../utilization-category-procedure-support.ts | 9 +- 8 files changed, 727 insertions(+), 571 deletions(-) diff --git a/packages/api/src/__tests__/assistant-tools-master-data-management-utilization-read.test.ts b/packages/api/src/__tests__/assistant-tools-master-data-management-utilization-read.test.ts index 547b027..158f642 100644 --- a/packages/api/src/__tests__/assistant-tools-master-data-management-utilization-read.test.ts +++ b/packages/api/src/__tests__/assistant-tools-master-data-management-utilization-read.test.ts @@ -43,17 +43,9 @@ describe("assistant master data management and utilization read tools", () => { description: "Client work", isActive: true, sortOrder: 1, + _count: { projects: 3 }, }, ]), - findUnique: vi.fn().mockResolvedValue({ - id: "util_billable", - code: "BILLABLE", - name: "Billable", - description: "Client work", - isActive: true, - sortOrder: 1, - _count: { projects: 3 }, - }), }, }; const ctx = createToolContext(db, { @@ -71,9 +63,6 @@ describe("assistant master data management and utilization read tools", () => { expect(db.utilizationCategory.findMany).toHaveBeenCalledWith({ where: {}, orderBy: { sortOrder: "asc" }, - }); - expect(db.utilizationCategory.findUnique).toHaveBeenCalledWith({ - where: { id: "util_billable" }, include: { _count: { select: { projects: true } } }, }); diff --git a/packages/api/src/__tests__/master-data-router-auth.test.ts b/packages/api/src/__tests__/master-data-router-auth.test.ts index 37aac83..acf51ce 100644 --- a/packages/api/src/__tests__/master-data-router-auth.test.ts +++ b/packages/api/src/__tests__/master-data-router-auth.test.ts @@ -37,11 +37,13 @@ function createUnauthenticatedContext(db: Record) { describe("master-data router authorization", () => { it("requires planning read access for blueprint summaries with project counts", async () => { const findMany = vi.fn(); - const caller = createCallerFactory(blueprintRouter)(createProtectedContext({ - blueprint: { - findMany, - }, - })); + const caller = createCallerFactory(blueprintRouter)( + createProtectedContext({ + blueprint: { + findMany, + }, + }), + ); await expect(caller.listSummaries()).rejects.toMatchObject({ code: "FORBIDDEN", @@ -59,13 +61,18 @@ describe("master-data router authorization", () => { _count: { projects: 4 }, }, ]); - const caller = createCallerFactory(blueprintRouter)(createProtectedContext({ - blueprint: { - findMany, - }, - }, { - granted: [PermissionKey.VIEW_PLANNING], - })); + const caller = createCallerFactory(blueprintRouter)( + createProtectedContext( + { + blueprint: { + findMany, + }, + }, + { + granted: [PermissionKey.VIEW_PLANNING], + }, + ), + ); const result = await caller.listSummaries(); @@ -85,19 +92,23 @@ describe("master-data router authorization", () => { const findMany = vi.fn(); const findUnique = vi.fn(); const findFirst = vi.fn(); - const caller = createCallerFactory(blueprintRouter)(createProtectedContext({ - blueprint: { - findMany, - findUnique, - findFirst, - }, - })); + const caller = createCallerFactory(blueprintRouter)( + createProtectedContext({ + blueprint: { + findMany, + findUnique, + findFirst, + }, + }), + ); await expect(caller.list({ isActive: true })).rejects.toMatchObject({ code: "FORBIDDEN", message: "Planning read access required", }); - await expect(caller.getByIdentifier({ identifier: "Consulting Blueprint" })).rejects.toMatchObject({ + await expect( + caller.getByIdentifier({ identifier: "Consulting Blueprint" }), + ).rejects.toMatchObject({ code: "FORBIDDEN", message: "Planning read access required", }); @@ -121,7 +132,8 @@ describe("master-data router authorization", () => { isActive: true, }, ]); - const getByIdFindUnique = vi.fn() + const getByIdFindUnique = vi + .fn() .mockResolvedValueOnce({ id: "bp_1", name: "Consulting Blueprint", @@ -145,15 +157,20 @@ describe("master-data router authorization", () => { rolePresets: [], isActive: true, }); - const caller = createCallerFactory(blueprintRouter)(createProtectedContext({ - blueprint: { - findMany: listFindMany, - findUnique: getByIdFindUnique, - findFirst: getByIdentifierFindFirst, - }, - }, { - granted: [PermissionKey.VIEW_PLANNING], - })); + const caller = createCallerFactory(blueprintRouter)( + createProtectedContext( + { + blueprint: { + findMany: listFindMany, + findUnique: getByIdFindUnique, + findFirst: getByIdentifierFindFirst, + }, + }, + { + granted: [PermissionKey.VIEW_PLANNING], + }, + ), + ); const listResult = await caller.list({ target: BlueprintTarget.PROJECT, isActive: true }); const byIdResult = await caller.getById({ id: "bp_1" }); @@ -177,22 +194,30 @@ describe("master-data router authorization", () => { it("requires authenticated planning access for global blueprint field definitions", async () => { const findMany = vi.fn(); - const unauthenticatedCaller = createCallerFactory(blueprintRouter)(createUnauthenticatedContext({ - blueprint: { - findMany, - }, - })); - const authenticatedCaller = createCallerFactory(blueprintRouter)(createProtectedContext({ - blueprint: { - findMany, - }, - })); + const unauthenticatedCaller = createCallerFactory(blueprintRouter)( + createUnauthenticatedContext({ + blueprint: { + findMany, + }, + }), + ); + const authenticatedCaller = createCallerFactory(blueprintRouter)( + createProtectedContext({ + blueprint: { + findMany, + }, + }), + ); - await expect(unauthenticatedCaller.getGlobalFieldDefs({ target: BlueprintTarget.PROJECT })).rejects.toMatchObject({ + await expect( + unauthenticatedCaller.getGlobalFieldDefs({ target: BlueprintTarget.PROJECT }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED", message: "Authentication required", }); - await expect(authenticatedCaller.getGlobalFieldDefs({ target: BlueprintTarget.PROJECT })).rejects.toMatchObject({ + await expect( + authenticatedCaller.getGlobalFieldDefs({ target: BlueprintTarget.PROJECT }), + ).rejects.toMatchObject({ code: "FORBIDDEN", message: "Planning read access required", }); @@ -211,13 +236,18 @@ describe("master-data router authorization", () => { ], }, ]); - const caller = createCallerFactory(blueprintRouter)(createProtectedContext({ - blueprint: { - findMany, - }, - }, { - granted: [PermissionKey.VIEW_PLANNING], - })); + const caller = createCallerFactory(blueprintRouter)( + createProtectedContext( + { + blueprint: { + findMany, + }, + }, + { + granted: [PermissionKey.VIEW_PLANNING], + }, + ), + ); const result = await caller.getGlobalFieldDefs({ target: BlueprintTarget.PROJECT }); @@ -254,20 +284,24 @@ describe("master-data router authorization", () => { metroCities: [{ id: "city_ber", name: "Berlin", countryId: "country_de" }], }, ]); - const caller = createCallerFactory(countryRouter)(createProtectedContext({ - country: { - findMany, - }, - })); + const caller = createCallerFactory(countryRouter)( + createProtectedContext({ + country: { + findMany, + }, + }), + ); const result = await caller.list({ isActive: true }); expect(result).toHaveLength(1); - expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: { isActive: true }, - include: { metroCities: { orderBy: { name: "asc" } } }, - orderBy: { name: "asc" }, - })); + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { isActive: true }, + include: { metroCities: { orderBy: { name: "asc" } } }, + orderBy: { name: "asc" }, + }), + ); }); it("keeps minimal country lookups available to authenticated users", async () => { @@ -278,12 +312,14 @@ describe("master-data router authorization", () => { isActive: true, dailyWorkingHours: 8, }); - const caller = createCallerFactory(countryRouter)(createProtectedContext({ - country: { - findUnique: vi.fn().mockResolvedValue(null), - findFirst, - }, - })); + const caller = createCallerFactory(countryRouter)( + createProtectedContext({ + country: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst, + }, + }), + ); const result = await caller.resolveByIdentifier({ identifier: "de" }); @@ -303,11 +339,13 @@ describe("master-data router authorization", () => { name: "Munich", countryId: "country_de", }); - const caller = createCallerFactory(countryRouter)(createProtectedContext({ - metroCity: { - findUnique, - }, - })); + const caller = createCallerFactory(countryRouter)( + createProtectedContext({ + metroCity: { + findUnique, + }, + }), + ); const result = await caller.getCityById({ id: "city_muc" }); @@ -323,12 +361,14 @@ describe("master-data router authorization", () => { }); it("requires resource overview access for detailed country reads with resource counts", async () => { - const caller = createCallerFactory(countryRouter)(createProtectedContext({ - country: { - findFirst: vi.fn(), - findUnique: vi.fn(), - }, - })); + const caller = createCallerFactory(countryRouter)( + createProtectedContext({ + country: { + findFirst: vi.fn(), + findUnique: vi.fn(), + }, + }), + ); await expect(caller.getByIdentifier({ identifier: "DE" })).rejects.toMatchObject({ code: "FORBIDDEN", @@ -351,24 +391,31 @@ describe("master-data router authorization", () => { metroCities: [{ id: "city_muc", name: "Munich", countryId: "country_de" }], _count: { resources: 12 }, }); - const caller = createCallerFactory(countryRouter)(createProtectedContext({ - country: { - findUnique: vi.fn().mockResolvedValue(null), - findFirst, - }, - }, { - granted: [PermissionKey.VIEW_ALL_RESOURCES], - })); + const caller = createCallerFactory(countryRouter)( + createProtectedContext( + { + country: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst, + }, + }, + { + granted: [PermissionKey.VIEW_ALL_RESOURCES], + }, + ), + ); const result = await caller.getByIdentifier({ identifier: "DE" }); expect(result._count.resources).toBe(12); - expect(findFirst).toHaveBeenCalledWith(expect.objectContaining({ - include: expect.objectContaining({ - metroCities: expect.any(Object), - _count: expect.any(Object), + expect(findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + include: expect.objectContaining({ + metroCities: expect.any(Object), + _count: expect.any(Object), + }), }), - })); + ); }); it("allows detailed country reads for users with manage-resources access", async () => { @@ -382,13 +429,18 @@ describe("master-data router authorization", () => { metroCities: [], _count: { resources: 4 }, }); - const caller = createCallerFactory(countryRouter)(createProtectedContext({ - country: { - findUnique, - }, - }, { - granted: [PermissionKey.MANAGE_RESOURCES], - })); + const caller = createCallerFactory(countryRouter)( + createProtectedContext( + { + country: { + findUnique, + }, + }, + { + granted: [PermissionKey.MANAGE_RESOURCES], + }, + ), + ); const result = await caller.getById({ id: "country_de" }); @@ -397,21 +449,21 @@ describe("master-data router authorization", () => { }); it("keeps minimal org-unit lookups available to authenticated users", async () => { - const findFirst = vi.fn() - .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ - id: "ou_1", - name: "Delivery", - shortName: "DEL", - level: 5, - isActive: true, - }); - const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({ - orgUnit: { - findUnique: vi.fn().mockResolvedValue(null), - findFirst, - }, - })); + const findFirst = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({ + id: "ou_1", + name: "Delivery", + shortName: "DEL", + level: 5, + isActive: true, + }); + const caller = createCallerFactory(orgUnitRouter)( + createProtectedContext({ + orgUnit: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst, + }, + }), + ); const result = await caller.resolveByIdentifier({ identifier: "DEL" }); @@ -426,11 +478,13 @@ describe("master-data router authorization", () => { it("requires resource overview access for org-unit list and tree reads", async () => { const findMany = vi.fn(); - const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({ - orgUnit: { - findMany, - }, - })); + const caller = createCallerFactory(orgUnitRouter)( + createProtectedContext({ + orgUnit: { + findMany, + }, + }), + ); await expect(caller.list({ level: 5, isActive: true })).rejects.toMatchObject({ code: "FORBIDDEN", @@ -445,12 +499,14 @@ describe("master-data router authorization", () => { }); it("requires resource overview access for detailed org-unit reads with staffing counts", async () => { - const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({ - orgUnit: { - findFirst: vi.fn(), - findUnique: vi.fn(), - }, - })); + const caller = createCallerFactory(orgUnitRouter)( + createProtectedContext({ + orgUnit: { + findFirst: vi.fn(), + findUnique: vi.fn(), + }, + }), + ); await expect(caller.getByIdentifier({ identifier: "Delivery" })).rejects.toMatchObject({ code: "FORBIDDEN", @@ -473,21 +529,28 @@ describe("master-data router authorization", () => { isActive: true, _count: { resources: 7 }, }); - const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({ - orgUnit: { - findUnique: vi.fn().mockResolvedValue(null), - findFirst, - }, - }, { - granted: [PermissionKey.VIEW_ALL_RESOURCES], - })); + const caller = createCallerFactory(orgUnitRouter)( + createProtectedContext( + { + orgUnit: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst, + }, + }, + { + granted: [PermissionKey.VIEW_ALL_RESOURCES], + }, + ), + ); const result = await caller.getByIdentifier({ identifier: "Delivery" }); expect(result._count.resources).toBe(7); - expect(findFirst).toHaveBeenCalledWith(expect.objectContaining({ - include: { _count: { select: { resources: true } } }, - })); + expect(findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + include: { _count: { select: { resources: true } } }, + }), + ); }); it("allows org-unit list and tree reads for users with resource overview access", async () => { @@ -517,15 +580,21 @@ describe("master-data router authorization", () => { updatedAt: new Date("2026-03-01T00:00:00.000Z"), }, ]); - const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({ - orgUnit: { - findMany: vi.fn() - .mockImplementationOnce(listFindMany) - .mockImplementationOnce(treeFindMany), - }, - }, { - granted: [PermissionKey.MANAGE_RESOURCES], - })); + const caller = createCallerFactory(orgUnitRouter)( + createProtectedContext( + { + orgUnit: { + findMany: vi + .fn() + .mockImplementationOnce(listFindMany) + .mockImplementationOnce(treeFindMany), + }, + }, + { + granted: [PermissionKey.MANAGE_RESOURCES], + }, + ), + ); const listResult = await caller.list({ level: 5, isActive: true }); const treeResult = await caller.getTree({ isActive: true }); @@ -549,21 +618,21 @@ describe("master-data router authorization", () => { }); it("keeps minimal client lookups available to authenticated users", async () => { - const findUnique = vi.fn() - .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ - id: "client_1", - name: "Acme Studios", - code: "ACME", - parentId: null, - isActive: true, - }); - const caller = createCallerFactory(clientRouter)(createProtectedContext({ - client: { - findUnique, - findFirst: vi.fn(), - }, - })); + const findUnique = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({ + id: "client_1", + name: "Acme Studios", + code: "ACME", + parentId: null, + isActive: true, + }); + const caller = createCallerFactory(clientRouter)( + createProtectedContext({ + client: { + findUnique, + findFirst: vi.fn(), + }, + }), + ); const result = await caller.resolveByIdentifier({ identifier: "ACME" }); @@ -578,11 +647,13 @@ describe("master-data router authorization", () => { it("requires planning read access for client list and tree reads", async () => { const findMany = vi.fn(); - const caller = createCallerFactory(clientRouter)(createProtectedContext({ - client: { - findMany, - }, - })); + const caller = createCallerFactory(clientRouter)( + createProtectedContext({ + client: { + findMany, + }, + }), + ); await expect(caller.list({ isActive: true })).rejects.toMatchObject({ code: "FORBIDDEN", @@ -597,12 +668,14 @@ describe("master-data router authorization", () => { }); it("requires planning read access for detailed client reads with project counts", async () => { - const caller = createCallerFactory(clientRouter)(createProtectedContext({ - client: { - findFirst: vi.fn(), - findUnique: vi.fn(), - }, - })); + const caller = createCallerFactory(clientRouter)( + createProtectedContext({ + client: { + findFirst: vi.fn(), + findUnique: vi.fn(), + }, + }), + ); await expect(caller.getByIdentifier({ identifier: "Acme" })).rejects.toMatchObject({ code: "FORBIDDEN", @@ -642,7 +715,8 @@ describe("master-data router authorization", () => { updatedAt: new Date("2026-03-01T00:00:00.000Z"), }, ]); - const getByIdFindUnique = vi.fn() + const getByIdFindUnique = vi + .fn() .mockResolvedValueOnce({ id: "client_1", name: "Acme Studios", @@ -670,17 +744,23 @@ describe("master-data router authorization", () => { updatedAt: new Date("2026-03-01T00:00:00.000Z"), _count: { children: 1, projects: 4 }, }); - const caller = createCallerFactory(clientRouter)(createProtectedContext({ - client: { - findMany: vi.fn() - .mockImplementationOnce(listFindMany) - .mockImplementationOnce(treeFindMany), - findUnique: getByIdFindUnique, - findFirst: getByIdentifierFindFirst, - }, - }, { - granted: [PermissionKey.VIEW_PLANNING], - })); + const caller = createCallerFactory(clientRouter)( + createProtectedContext( + { + client: { + findMany: vi + .fn() + .mockImplementationOnce(listFindMany) + .mockImplementationOnce(treeFindMany), + findUnique: getByIdFindUnique, + findFirst: getByIdentifierFindFirst, + }, + }, + { + granted: [PermissionKey.VIEW_PLANNING], + }, + ), + ); const listResult = await caller.list({ isActive: true, search: "Acme" }); const treeResult = await caller.getTree({ isActive: true }); @@ -720,21 +800,25 @@ describe("master-data router authorization", () => { _count: { select: { projects: true, children: true } }, }, }); - expect(getByIdentifierFindFirst).toHaveBeenCalledWith(expect.objectContaining({ - where: { name: { equals: "Acme Studios", mode: "insensitive" } }, - include: { _count: { select: { projects: true, children: true } } }, - })); + expect(getByIdentifierFindFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { name: { equals: "Acme Studios", mode: "insensitive" } }, + include: { _count: { select: { projects: true, children: true } } }, + }), + ); }); it("requires planning read access for utilization-category reads with project counts", async () => { const listFindMany = vi.fn(); const getByIdFindUnique = vi.fn(); - const caller = createCallerFactory(utilizationCategoryRouter)(createProtectedContext({ - utilizationCategory: { - findMany: listFindMany, - findUnique: getByIdFindUnique, - }, - })); + const caller = createCallerFactory(utilizationCategoryRouter)( + createProtectedContext({ + utilizationCategory: { + findMany: listFindMany, + findUnique: getByIdFindUnique, + }, + }), + ); await expect(caller.list({ isActive: true })).rejects.toMatchObject({ code: "FORBIDDEN", @@ -771,14 +855,19 @@ describe("master-data router authorization", () => { isActive: true, _count: { projects: 12 }, }); - const caller = createCallerFactory(utilizationCategoryRouter)(createProtectedContext({ - utilizationCategory: { - findMany: listFindMany, - findUnique: getByIdFindUnique, - }, - }, { - granted: [PermissionKey.VIEW_PLANNING], - })); + const caller = createCallerFactory(utilizationCategoryRouter)( + createProtectedContext( + { + utilizationCategory: { + findMany: listFindMany, + findUnique: getByIdFindUnique, + }, + }, + { + granted: [PermissionKey.VIEW_PLANNING], + }, + ), + ); const listResult = await caller.list({ isActive: true }); const byIdResult = await caller.getById({ id: "util_chargeable" }); @@ -788,6 +877,7 @@ describe("master-data router authorization", () => { expect(listFindMany).toHaveBeenCalledWith({ where: { isActive: true }, orderBy: { sortOrder: "asc" }, + include: { _count: { select: { projects: true } } }, }); expect(getByIdFindUnique).toHaveBeenCalledWith({ where: { id: "util_chargeable" }, @@ -798,12 +888,14 @@ describe("master-data router authorization", () => { it("requires planning read access for management-level reads", async () => { const listFindMany = vi.fn(); const getByIdFindUnique = vi.fn(); - const caller = createCallerFactory(managementLevelRouter)(createProtectedContext({ - managementLevelGroup: { - findMany: listFindMany, - findUnique: getByIdFindUnique, - }, - })); + const caller = createCallerFactory(managementLevelRouter)( + createProtectedContext({ + managementLevelGroup: { + findMany: listFindMany, + findUnique: getByIdFindUnique, + }, + }), + ); await expect(caller.listGroups()).rejects.toMatchObject({ code: "FORBIDDEN", @@ -836,14 +928,19 @@ describe("master-data router authorization", () => { levels: [{ id: "mgmt_level_1", name: "Senior Team Lead" }], _count: { resources: 6 }, }); - const caller = createCallerFactory(managementLevelRouter)(createProtectedContext({ - managementLevelGroup: { - findMany: listFindMany, - findUnique: getByIdFindUnique, - }, - }, { - granted: [PermissionKey.VIEW_PLANNING], - })); + const caller = createCallerFactory(managementLevelRouter)( + createProtectedContext( + { + managementLevelGroup: { + findMany: listFindMany, + findUnique: getByIdFindUnique, + }, + }, + { + granted: [PermissionKey.VIEW_PLANNING], + }, + ), + ); const listResult = await caller.listGroups(); const detailResult = await caller.getGroupById({ id: "mgmt_group_1" }); diff --git a/packages/api/src/__tests__/utilization-category-router.test.ts b/packages/api/src/__tests__/utilization-category-router.test.ts index babed3e..57ce989 100644 --- a/packages/api/src/__tests__/utilization-category-router.test.ts +++ b/packages/api/src/__tests__/utilization-category-router.test.ts @@ -48,6 +48,7 @@ describe("utilization category router", () => { expect(findMany).toHaveBeenCalledWith({ where: { isActive: true }, orderBy: { sortOrder: "asc" }, + include: { _count: { select: { projects: true } } }, }); }); diff --git a/packages/api/src/lib/rate-card-lookup.ts b/packages/api/src/lib/rate-card-lookup.ts index a3f568a..625c72c 100644 --- a/packages/api/src/lib/rate-card-lookup.ts +++ b/packages/api/src/lib/rate-card-lookup.ts @@ -13,13 +13,13 @@ */ export interface RateCardLookupParams { - clientId?: string | null; - chapter?: string | null; - roleId?: string | null; - seniority?: string | null; - location?: string | null; - workType?: string | null; - effectiveDate?: Date | null; + clientId?: string | null | undefined; + chapter?: string | null | undefined; + roleId?: string | null | undefined; + seniority?: string | null | undefined; + location?: string | null | undefined; + workType?: string | null | undefined; + effectiveDate?: Date | null | undefined; } export interface RateCardLookupResult { @@ -49,6 +49,115 @@ interface RateCardLineRow { }; } +const RATE_CARD_LINE_SELECT = { + id: true, + rateCardId: true, + roleId: true, + chapter: true, + location: true, + seniority: true, + workType: true, + costRateCents: true, + billRateCents: true, + rateCard: { + select: { + id: true, + name: true, + currency: true, + clientId: true, + }, + }, +} as const; + +function scoreLine( + line: RateCardLineRow, + params: RateCardLookupParams, +): { score: number; mismatch: boolean } { + let score = 0; + let mismatch = false; + + if (params.clientId && line.rateCard.clientId === params.clientId) { + score += 100; + } else if (params.clientId && line.rateCard.clientId != null) { + mismatch = true; + } + + if (params.roleId && line.roleId) { + if (line.roleId === params.roleId) score += 16; + else mismatch = true; + } + + if (params.chapter && line.chapter) { + if (line.chapter === params.chapter) score += 8; + else mismatch = true; + } + + if (params.seniority && line.seniority) { + if (line.seniority === params.seniority) score += 4; + else mismatch = true; + } + + if (params.location && line.location) { + if (line.location === params.location) score += 2; + else mismatch = true; + } + + if (params.workType && line.workType) { + if (line.workType === params.workType) score += 1; + else mismatch = true; + } + + return { score, mismatch }; +} + +function findBestMatch( + lines: RateCardLineRow[], + params: RateCardLookupParams, +): RateCardLookupResult | null { + if (lines.length === 0) return null; + + let bestLine: RateCardLineRow | null = null; + let bestScore = -1; + + for (const line of lines) { + const { score, mismatch } = scoreLine(line, params); + if (!mismatch && score > bestScore) { + bestScore = score; + bestLine = line; + } + } + + if (!bestLine) return null; + + return { + costRateCents: bestLine.costRateCents, + billRateCents: bestLine.billRateCents ?? 0, + currency: bestLine.rateCard.currency, + rateCardId: bestLine.rateCard.id, + rateCardLineId: bestLine.id, + rateCardName: bestLine.rateCard.name, + }; +} + +function buildRateCardWhere( + clientId: string | null | undefined, + effectiveDate: Date, +): Record { + const where: Record = { + isActive: true, + OR: [{ effectiveFrom: null }, { effectiveFrom: { lte: effectiveDate } }], + AND: [ + { + OR: [{ effectiveTo: null }, { effectiveTo: { gte: effectiveDate } }], + }, + ], + }; + if (clientId) { + where.clientId = { in: [clientId, null] }; + } + return where; +} + /** * Look up the best-matching rate card line for a given set of criteria. * Returns null when no active rate card line matches. @@ -59,120 +168,32 @@ export async function lookupRate( params: RateCardLookupParams, ): Promise { const effectiveDate = params.effectiveDate ?? new Date(); - - // Build rate card filter: active cards, within effective date range - const rateCardWhere: Record = { - isActive: true, - OR: [ - { effectiveFrom: null }, - { effectiveFrom: { lte: effectiveDate } }, - ], - AND: [ - { - OR: [ - { effectiveTo: null }, - { effectiveTo: { gte: effectiveDate } }, - ], - }, - ], - }; - - // If we have a clientId, look for both client-specific and default (null client) cards - if (params.clientId) { - rateCardWhere.clientId = { in: [params.clientId, null] }; - } - // If no clientId, only look at default (null client) cards - // (don't pass clientId filter at all to keep the OR above valid) - const lines = (await db.rateCardLine.findMany({ - where: { - rateCard: rateCardWhere, - }, - select: { - id: true, - rateCardId: true, - roleId: true, - chapter: true, - location: true, - seniority: true, - workType: true, - costRateCents: true, - billRateCents: true, - rateCard: { - select: { - id: true, - name: true, - currency: true, - clientId: true, - }, - }, - }, + where: { rateCard: buildRateCardWhere(params.clientId, effectiveDate) }, + select: RATE_CARD_LINE_SELECT, })) as RateCardLineRow[]; - if (lines.length === 0) return null; - - // Score each line. Higher = better match. - type ScoredLine = { line: RateCardLineRow; score: number; mismatch: boolean }; - const scored: ScoredLine[] = lines.map((line) => { - let score = 0; - let mismatch = false; - - // Client specificity: client-specific cards get a large bonus - if (params.clientId && line.rateCard.clientId === params.clientId) { - score += 100; - } else if (params.clientId && line.rateCard.clientId != null) { - // Different client entirely => disqualify - mismatch = true; - } - // Default card (null client) gets no bonus but is a valid fallback - - // Role match - if (params.roleId && line.roleId) { - if (line.roleId === params.roleId) score += 16; - else mismatch = true; - } - - // Chapter match - if (params.chapter && line.chapter) { - if (line.chapter === params.chapter) score += 8; - else mismatch = true; - } - - // Seniority match - if (params.seniority && line.seniority) { - if (line.seniority === params.seniority) score += 4; - else mismatch = true; - } - - // Location match - if (params.location && line.location) { - if (line.location === params.location) score += 2; - else mismatch = true; - } - - // Work type match - if (params.workType && line.workType) { - if (line.workType === params.workType) score += 1; - else mismatch = true; - } - - return { line, score, mismatch }; - }); - - // Filter out mismatched lines and sort by score descending - const candidates = scored - .filter((s) => !s.mismatch) - .sort((a, b) => b.score - a.score); - - const best = candidates[0]; - if (!best) return null; - - return { - costRateCents: best.line.costRateCents, - billRateCents: best.line.billRateCents ?? 0, - currency: best.line.rateCard.currency, - rateCardId: best.line.rateCard.id, - rateCardLineId: best.line.id, - rateCardName: best.line.rateCard.name, - }; + return findBestMatch(lines, params); +} + +/** + * Batch-optimized rate lookup: loads rate card lines once, then scores + * each param set against the cached lines. Use this when looking up rates + * for multiple demand lines sharing the same clientId/effectiveDate. + */ +export async function lookupRatesBatch( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db: any, + clientId: string | null | undefined, + paramsList: RateCardLookupParams[], +): Promise<(RateCardLookupResult | null)[]> { + if (paramsList.length === 0) return []; + + const effectiveDate = paramsList[0]?.effectiveDate ?? new Date(); + const lines = (await db.rateCardLine.findMany({ + where: { rateCard: buildRateCardWhere(clientId, effectiveDate) }, + select: RATE_CARD_LINE_SELECT, + })) as RateCardLineRow[]; + + return paramsList.map((params) => findBestMatch(lines, { ...params, clientId })); } diff --git a/packages/api/src/router/assistant-tools/config-readmodels.ts b/packages/api/src/router/assistant-tools/config-readmodels.ts index 3d7b117..7eb3dd3 100644 --- a/packages/api/src/router/assistant-tools/config-readmodels.ts +++ b/packages/api/src/router/assistant-tools/config-readmodels.ts @@ -4,138 +4,149 @@ import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } fro type ConfigReadmodelsDeps = { createManagementLevelCaller: (ctx: TRPCContext) => { - listGroups: () => Promise; - }>>; - }; - createUtilizationCategoryCaller: (ctx: TRPCContext) => { - list: () => Promise>; - getById: (params: { id: string }) => Promise<{ - _count: { projects: number }; - }>; - }; - createCalculationRuleCaller: (ctx: TRPCContext) => { - list: () => Promise Promise< + Array<{ id: string; name: string; - shortCode: string; - } | null; - }>>; + targetPercentage: number | null; + levels: Array<{ id: string; name: string }>; + }> + >; + }; + createUtilizationCategoryCaller: (ctx: TRPCContext) => { + list: () => Promise< + Array<{ + id: string; + code: string; + name: string; + description: string | null; + _count: { projects: number }; + }> + >; + }; + createCalculationRuleCaller: (ctx: TRPCContext) => { + list: () => Promise< + Array<{ + id: string; + name: string; + description: string | null; + isActive: boolean; + triggerType: string; + orderType: string | null; + costEffect: string; + costReductionPercent: number | null; + chargeabilityEffect: string; + priority: number; + project: { + id: string; + name: string; + shortCode: string; + } | null; + }> + >; }; createEffortRuleCaller: (ctx: TRPCContext) => { - list: () => Promise; - }>>; + list: () => Promise< + Array<{ + name: string; + isDefault: boolean; + rules: Array<{ + id: string; + description: string | null; + scopeType: string; + discipline: string; + chapter: string | null; + unitMode: string; + hoursPerUnit: number; + sortOrder: number; + }>; + }> + >; }; createExperienceMultiplierCaller: (ctx: TRPCContext) => { - list: () => Promise; - }>>; + list: () => Promise< + Array<{ + name: string; + isDefault: boolean; + rules: Array<{ + id: string; + description: string | null; + chapter: string | null; + location: string | null; + level: string | null; + costMultiplier: number; + billMultiplier: number; + shoringRatio: number | null; + additionalEffortRatio: number | null; + sortOrder: number; + }>; + }> + >; }; createScopedCallerContext: (ctx: ToolContext) => TRPCContext; }; -export const configReadmodelToolDefinitions: ToolDef[] = withToolAccess([ +export const configReadmodelToolDefinitions: ToolDef[] = withToolAccess( + [ + { + type: "function", + function: { + name: "list_management_levels", + description: "List management level groups and their levels with target percentages.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "list_utilization_categories", + description: "List utilization categories (cost classification for projects).", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "list_calculation_rules", + description: "List calculation rules for cost attribution and chargeability.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "list_effort_rules", + description: "List effort estimation rules with their formulas and conditions.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "list_experience_multipliers", + description: "List experience multipliers that adjust effort estimates based on seniority.", + parameters: { type: "object", properties: {} }, + }, + }, + ], { - type: "function", - function: { - name: "list_management_levels", - description: "List management level groups and their levels with target percentages.", - parameters: { type: "object", properties: {} }, + list_management_levels: { + requiresPlanningRead: true, + }, + list_utilization_categories: { + requiresPlanningRead: true, + }, + list_calculation_rules: { + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + }, + list_effort_rules: { + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + }, + list_experience_multipliers: { + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], }, }, - { - type: "function", - function: { - name: "list_utilization_categories", - description: "List utilization categories (cost classification for projects).", - parameters: { type: "object", properties: {} }, - }, - }, - { - type: "function", - function: { - name: "list_calculation_rules", - description: "List calculation rules for cost attribution and chargeability.", - parameters: { type: "object", properties: {} }, - }, - }, - { - type: "function", - function: { - name: "list_effort_rules", - description: "List effort estimation rules with their formulas and conditions.", - parameters: { type: "object", properties: {} }, - }, - }, - { - type: "function", - function: { - name: "list_experience_multipliers", - description: "List experience multipliers that adjust effort estimates based on seniority.", - parameters: { type: "object", properties: {} }, - }, - }, -], { - list_management_levels: { - requiresPlanningRead: true, - }, - list_utilization_categories: { - requiresPlanningRead: true, - }, - list_calculation_rules: { - allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], - }, - list_effort_rules: { - allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], - }, - list_experience_multipliers: { - allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], - }, -}); +); export function createConfigReadmodelExecutors( deps: ConfigReadmodelsDeps, @@ -155,19 +166,13 @@ export function createConfigReadmodelExecutors( async list_utilization_categories(_params: Record, ctx: ToolContext) { const caller = deps.createUtilizationCategoryCaller(deps.createScopedCallerContext(ctx)); const categories = await caller.list(); - const categoriesWithCounts = await Promise.all( - categories.map(async (category) => ({ - category, - projectCount: (await caller.getById({ id: category.id }))._count.projects, - })), - ); - return categoriesWithCounts.map(({ category, projectCount }) => ({ + return categories.map((category) => ({ id: category.id, code: category.code, name: category.name, description: category.description, - projectCount, + projectCount: category._count.projects, })); }, @@ -198,41 +203,45 @@ export function createConfigReadmodelExecutors( async list_effort_rules(_params: Record, ctx: ToolContext) { const caller = deps.createEffortRuleCaller(deps.createScopedCallerContext(ctx)); const ruleSets = await caller.list(); - return ruleSets.flatMap((ruleSet) => ruleSet.rules.map((rule) => ({ - id: rule.id, - description: rule.description, - scopeType: rule.scopeType, - discipline: rule.discipline, - chapter: rule.chapter, - unitMode: rule.unitMode, - hoursPerUnit: rule.hoursPerUnit, - sortOrder: rule.sortOrder, - ruleSet: { - name: ruleSet.name, - isDefault: ruleSet.isDefault, - }, - }))); + return ruleSets.flatMap((ruleSet) => + ruleSet.rules.map((rule) => ({ + id: rule.id, + description: rule.description, + scopeType: rule.scopeType, + discipline: rule.discipline, + chapter: rule.chapter, + unitMode: rule.unitMode, + hoursPerUnit: rule.hoursPerUnit, + sortOrder: rule.sortOrder, + ruleSet: { + name: ruleSet.name, + isDefault: ruleSet.isDefault, + }, + })), + ); }, async list_experience_multipliers(_params: Record, ctx: ToolContext) { const caller = deps.createExperienceMultiplierCaller(deps.createScopedCallerContext(ctx)); const multiplierSets = await caller.list(); - return multiplierSets.flatMap((multiplierSet) => multiplierSet.rules.map((rule) => ({ - id: rule.id, - description: rule.description, - chapter: rule.chapter, - location: rule.location, - level: rule.level, - costMultiplier: rule.costMultiplier, - billMultiplier: rule.billMultiplier, - shoringRatio: rule.shoringRatio, - additionalEffortRatio: rule.additionalEffortRatio, - sortOrder: rule.sortOrder, - multiplierSet: { - name: multiplierSet.name, - isDefault: multiplierSet.isDefault, - }, - }))); + return multiplierSets.flatMap((multiplierSet) => + multiplierSet.rules.map((rule) => ({ + id: rule.id, + description: rule.description, + chapter: rule.chapter, + location: rule.location, + level: rule.level, + costMultiplier: rule.costMultiplier, + billMultiplier: rule.billMultiplier, + shoringRatio: rule.shoringRatio, + additionalEffortRatio: rule.additionalEffortRatio, + sortOrder: rule.sortOrder, + multiplierSet: { + name: multiplierSet.name, + isDefault: multiplierSet.isDefault, + }, + })), + ); }, }; } diff --git a/packages/api/src/router/estimate-demand-lines.ts b/packages/api/src/router/estimate-demand-lines.ts index 1682e09..98de641 100644 --- a/packages/api/src/router/estimate-demand-lines.ts +++ b/packages/api/src/router/estimate-demand-lines.ts @@ -1,11 +1,9 @@ import { normalizeEstimateDemandLine, summarizeEstimateDemandLines } from "@capakraken/engine"; import { CreateEstimateSchema } from "@capakraken/shared"; import { z } from "zod"; -import { lookupRate } from "../lib/rate-card-lookup.js"; +import { lookupRatesBatch } from "../lib/rate-card-lookup.js"; -function buildComputedMetrics( - demandLines: z.infer["demandLines"], -) { +function buildComputedMetrics(demandLines: z.infer["demandLines"]) { const summary = summarizeEstimateDemandLines(demandLines); return [ @@ -62,7 +60,9 @@ function normalizeDemandLines< const snapshotsByResourceId = new Map( input.resourceSnapshots .filter( - (snapshot): snapshot is (typeof input.resourceSnapshots)[number] & { + ( + snapshot, + ): snapshot is (typeof input.resourceSnapshots)[number] & { resourceId: string; } => typeof snapshot.resourceId === "string" && snapshot.resourceId.length > 0, ) @@ -71,8 +71,7 @@ function normalizeDemandLines< return input.demandLines.map((line) => normalizeEstimateDemandLine(line, { - resourceSnapshot: - line.resourceId != null ? snapshotsByResourceId.get(line.resourceId) : null, + resourceSnapshot: line.resourceId != null ? snapshotsByResourceId.get(line.resourceId) : null, defaultCurrency: baseCurrency, }), ); @@ -117,44 +116,60 @@ export async function autoFillDemandLineRates( clientId = project?.clientId ?? null; } - const autoFilledIndices: number[] = []; - const enriched = await Promise.all( - demandLines.map(async (line, index) => { - const isDefaultRate = line.costRateCents === 0 && line.billRateCents === 0; - const hasExplicitSource = line.rateSource != null && line.rateSource.length > 0; - if (!isDefaultRate || hasExplicitSource) { - return line; - } - - const result = await lookupRate(db, { - clientId, - chapter: line.chapter ?? null, - roleId: line.roleId ?? null, + // Identify which lines need auto-fill and collect their lookup params + const needsLookup: { + index: number; + params: { chapter: string | null; roleId: string | null }; + }[] = []; + for (let i = 0; i < demandLines.length; i++) { + const line = demandLines[i]!; + const isDefaultRate = line.costRateCents === 0 && line.billRateCents === 0; + const hasExplicitSource = line.rateSource != null && line.rateSource.length > 0; + if (isDefaultRate && !hasExplicitSource) { + needsLookup.push({ + index: i, + params: { chapter: line.chapter ?? null, roleId: line.roleId ?? null }, }); - if (!result) { - return line; - } + } + } - autoFilledIndices.push(index); - const existingMetadata = (line.metadata ?? {}) as Record; - return { - ...line, - costRateCents: result.costRateCents, - billRateCents: result.billRateCents, - currency: result.currency, - rateSource: `rate-card:${result.rateCardId}`, - metadata: { - ...existingMetadata, - autoAppliedRateCard: { - rateCardId: result.rateCardId, - rateCardLineId: result.rateCardLineId, - rateCardName: result.rateCardName, - appliedAt: new Date().toISOString(), - }, - }, - }; - }), + if (needsLookup.length === 0) { + return { demandLines, autoFilledIndices: [] }; + } + + // Single DB query for all rate card lines, scored locally per demand line + const results = await lookupRatesBatch( + db, + clientId, + needsLookup.map((entry) => entry.params), ); + const autoFilledIndices: number[] = []; + const enriched = [...demandLines]; + for (let i = 0; i < needsLookup.length; i++) { + const result = results[i]; + if (!result) continue; + const { index } = needsLookup[i]!; + autoFilledIndices.push(index); + const line = demandLines[index]!; + const existingMetadata = (line.metadata ?? {}) as Record; + enriched[index] = { + ...line, + costRateCents: result.costRateCents, + billRateCents: result.billRateCents, + currency: result.currency, + rateSource: `rate-card:${result.rateCardId}`, + metadata: { + ...existingMetadata, + autoAppliedRateCard: { + rateCardId: result.rateCardId, + rateCardLineId: result.rateCardLineId, + rateCardName: result.rateCardName, + appliedAt: new Date().toISOString(), + }, + }, + }; + } + return { demandLines: enriched, autoFilledIndices }; } diff --git a/packages/api/src/router/timeline-holiday-load-support.ts b/packages/api/src/router/timeline-holiday-load-support.ts index fb689de..9d1463b 100644 --- a/packages/api/src/router/timeline-holiday-load-support.ts +++ b/packages/api/src/router/timeline-holiday-load-support.ts @@ -57,36 +57,57 @@ export async function loadTimelineHolidayOverlaysForReadModel( }, }); + // Group resources by location key to deduplicate holiday resolution. + // Resources sharing the same (countryId, federalState, metroCityId) get + // identical holidays, so we resolve once per location instead of once per resource. + const locationGroups = new Map< + string, + { locationResource: (typeof resources)[0]; resourceIds: string[] } + >(); + for (const resource of resources) { + const key = `${resource.countryId ?? ""}:${resource.federalState ?? ""}:${resource.metroCityId ?? ""}`; + const existing = locationGroups.get(key); + if (existing) { + existing.resourceIds.push(resource.id); + } else { + locationGroups.set(key, { locationResource: resource, resourceIds: [resource.id] }); + } + } + + const resolverDb = asHolidayResolverDb(db); const overlays = await Promise.all( - resources.map(async (resource) => { - const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), { + [...locationGroups.values()].map(async ({ locationResource, resourceIds }) => { + const holidays = await getResolvedCalendarHolidays(resolverDb, { periodStart: input.startDate, periodEnd: input.endDate, - countryId: resource.countryId, - countryCode: resource.country?.code ?? null, - federalState: resource.federalState, - metroCityId: resource.metroCityId, - metroCityName: resource.metroCity?.name ?? null, + countryId: locationResource.countryId, + countryCode: locationResource.country?.code ?? null, + federalState: locationResource.federalState, + metroCityId: locationResource.metroCityId, + metroCityName: locationResource.metroCity?.name ?? null, }); - return holidays.map((holiday) => { - const holidayDate = new Date(`${holiday.date}T00:00:00.000Z`); - return { - id: `calendar-holiday:${resource.id}:${holiday.date}`, - resourceId: resource.id, - type: VacationType.PUBLIC_HOLIDAY, - status: "APPROVED" as const, - startDate: holidayDate, - endDate: holidayDate, - note: holiday.name, - scope: holiday.scope, - calendarName: holiday.calendarName, - sourceType: holiday.sourceType, - countryCode: resource.country?.code ?? null, - countryName: resource.country?.name ?? null, - federalState: resource.federalState ?? null, - metroCityName: resource.metroCity?.name ?? null, - }; + return resourceIds.flatMap((resourceId) => { + const resource = resources.find((r) => r.id === resourceId)!; + return holidays.map((holiday) => { + const holidayDate = new Date(`${holiday.date}T00:00:00.000Z`); + return { + id: `calendar-holiday:${resourceId}:${holiday.date}`, + resourceId, + type: VacationType.PUBLIC_HOLIDAY, + status: "APPROVED" as const, + startDate: holidayDate, + endDate: holidayDate, + note: holiday.name, + scope: holiday.scope, + calendarName: holiday.calendarName, + sourceType: holiday.sourceType, + countryCode: resource.country?.code ?? null, + countryName: resource.country?.name ?? null, + federalState: resource.federalState ?? null, + metroCityName: resource.metroCity?.name ?? null, + }; + }); }); }), ); diff --git a/packages/api/src/router/utilization-category-procedure-support.ts b/packages/api/src/router/utilization-category-procedure-support.ts index c1700dc..bc9c9e3 100644 --- a/packages/api/src/router/utilization-category-procedure-support.ts +++ b/packages/api/src/router/utilization-category-procedure-support.ts @@ -14,9 +14,11 @@ import { unsetDefaultUtilizationCategory, } from "./utilization-category-support.js"; -export const UtilizationCategoryListInputSchema = z.object({ - isActive: z.boolean().optional(), -}).optional(); +export const UtilizationCategoryListInputSchema = z + .object({ + isActive: z.boolean().optional(), + }) + .optional(); export const UtilizationCategoryByIdInputSchema = z.object({ id: z.string(), @@ -38,6 +40,7 @@ export async function listUtilizationCategories( return ctx.db.utilizationCategory.findMany({ where: buildUtilizationCategoryListWhere(input ?? {}), orderBy: { sortOrder: "asc" }, + include: { _count: { select: { projects: true } } }, }); }