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 <noreply@anthropic.com>
This commit is contained in:
+1
-12
@@ -43,17 +43,9 @@ describe("assistant master data management and utilization read tools", () => {
|
|||||||
description: "Client work",
|
description: "Client work",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
sortOrder: 1,
|
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, {
|
const ctx = createToolContext(db, {
|
||||||
@@ -71,9 +63,6 @@ describe("assistant master data management and utilization read tools", () => {
|
|||||||
expect(db.utilizationCategory.findMany).toHaveBeenCalledWith({
|
expect(db.utilizationCategory.findMany).toHaveBeenCalledWith({
|
||||||
where: {},
|
where: {},
|
||||||
orderBy: { sortOrder: "asc" },
|
orderBy: { sortOrder: "asc" },
|
||||||
});
|
|
||||||
expect(db.utilizationCategory.findUnique).toHaveBeenCalledWith({
|
|
||||||
where: { id: "util_billable" },
|
|
||||||
include: { _count: { select: { projects: true } } },
|
include: { _count: { select: { projects: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -37,11 +37,13 @@ function createUnauthenticatedContext(db: Record<string, unknown>) {
|
|||||||
describe("master-data router authorization", () => {
|
describe("master-data router authorization", () => {
|
||||||
it("requires planning read access for blueprint summaries with project counts", async () => {
|
it("requires planning read access for blueprint summaries with project counts", async () => {
|
||||||
const findMany = vi.fn();
|
const findMany = vi.fn();
|
||||||
const caller = createCallerFactory(blueprintRouter)(createProtectedContext({
|
const caller = createCallerFactory(blueprintRouter)(
|
||||||
blueprint: {
|
createProtectedContext({
|
||||||
findMany,
|
blueprint: {
|
||||||
},
|
findMany,
|
||||||
}));
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.listSummaries()).rejects.toMatchObject({
|
await expect(caller.listSummaries()).rejects.toMatchObject({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
@@ -59,13 +61,18 @@ describe("master-data router authorization", () => {
|
|||||||
_count: { projects: 4 },
|
_count: { projects: 4 },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const caller = createCallerFactory(blueprintRouter)(createProtectedContext({
|
const caller = createCallerFactory(blueprintRouter)(
|
||||||
blueprint: {
|
createProtectedContext(
|
||||||
findMany,
|
{
|
||||||
},
|
blueprint: {
|
||||||
}, {
|
findMany,
|
||||||
granted: [PermissionKey.VIEW_PLANNING],
|
},
|
||||||
}));
|
},
|
||||||
|
{
|
||||||
|
granted: [PermissionKey.VIEW_PLANNING],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await caller.listSummaries();
|
const result = await caller.listSummaries();
|
||||||
|
|
||||||
@@ -85,19 +92,23 @@ describe("master-data router authorization", () => {
|
|||||||
const findMany = vi.fn();
|
const findMany = vi.fn();
|
||||||
const findUnique = vi.fn();
|
const findUnique = vi.fn();
|
||||||
const findFirst = vi.fn();
|
const findFirst = vi.fn();
|
||||||
const caller = createCallerFactory(blueprintRouter)(createProtectedContext({
|
const caller = createCallerFactory(blueprintRouter)(
|
||||||
blueprint: {
|
createProtectedContext({
|
||||||
findMany,
|
blueprint: {
|
||||||
findUnique,
|
findMany,
|
||||||
findFirst,
|
findUnique,
|
||||||
},
|
findFirst,
|
||||||
}));
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.list({ isActive: true })).rejects.toMatchObject({
|
await expect(caller.list({ isActive: true })).rejects.toMatchObject({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "Planning read access required",
|
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",
|
code: "FORBIDDEN",
|
||||||
message: "Planning read access required",
|
message: "Planning read access required",
|
||||||
});
|
});
|
||||||
@@ -121,7 +132,8 @@ describe("master-data router authorization", () => {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const getByIdFindUnique = vi.fn()
|
const getByIdFindUnique = vi
|
||||||
|
.fn()
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
id: "bp_1",
|
id: "bp_1",
|
||||||
name: "Consulting Blueprint",
|
name: "Consulting Blueprint",
|
||||||
@@ -145,15 +157,20 @@ describe("master-data router authorization", () => {
|
|||||||
rolePresets: [],
|
rolePresets: [],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
});
|
});
|
||||||
const caller = createCallerFactory(blueprintRouter)(createProtectedContext({
|
const caller = createCallerFactory(blueprintRouter)(
|
||||||
blueprint: {
|
createProtectedContext(
|
||||||
findMany: listFindMany,
|
{
|
||||||
findUnique: getByIdFindUnique,
|
blueprint: {
|
||||||
findFirst: getByIdentifierFindFirst,
|
findMany: listFindMany,
|
||||||
},
|
findUnique: getByIdFindUnique,
|
||||||
}, {
|
findFirst: getByIdentifierFindFirst,
|
||||||
granted: [PermissionKey.VIEW_PLANNING],
|
},
|
||||||
}));
|
},
|
||||||
|
{
|
||||||
|
granted: [PermissionKey.VIEW_PLANNING],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const listResult = await caller.list({ target: BlueprintTarget.PROJECT, isActive: true });
|
const listResult = await caller.list({ target: BlueprintTarget.PROJECT, isActive: true });
|
||||||
const byIdResult = await caller.getById({ id: "bp_1" });
|
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 () => {
|
it("requires authenticated planning access for global blueprint field definitions", async () => {
|
||||||
const findMany = vi.fn();
|
const findMany = vi.fn();
|
||||||
|
|
||||||
const unauthenticatedCaller = createCallerFactory(blueprintRouter)(createUnauthenticatedContext({
|
const unauthenticatedCaller = createCallerFactory(blueprintRouter)(
|
||||||
blueprint: {
|
createUnauthenticatedContext({
|
||||||
findMany,
|
blueprint: {
|
||||||
},
|
findMany,
|
||||||
}));
|
},
|
||||||
const authenticatedCaller = createCallerFactory(blueprintRouter)(createProtectedContext({
|
}),
|
||||||
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",
|
code: "UNAUTHORIZED",
|
||||||
message: "Authentication required",
|
message: "Authentication required",
|
||||||
});
|
});
|
||||||
await expect(authenticatedCaller.getGlobalFieldDefs({ target: BlueprintTarget.PROJECT })).rejects.toMatchObject({
|
await expect(
|
||||||
|
authenticatedCaller.getGlobalFieldDefs({ target: BlueprintTarget.PROJECT }),
|
||||||
|
).rejects.toMatchObject({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "Planning read access required",
|
message: "Planning read access required",
|
||||||
});
|
});
|
||||||
@@ -211,13 +236,18 @@ describe("master-data router authorization", () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const caller = createCallerFactory(blueprintRouter)(createProtectedContext({
|
const caller = createCallerFactory(blueprintRouter)(
|
||||||
blueprint: {
|
createProtectedContext(
|
||||||
findMany,
|
{
|
||||||
},
|
blueprint: {
|
||||||
}, {
|
findMany,
|
||||||
granted: [PermissionKey.VIEW_PLANNING],
|
},
|
||||||
}));
|
},
|
||||||
|
{
|
||||||
|
granted: [PermissionKey.VIEW_PLANNING],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await caller.getGlobalFieldDefs({ target: BlueprintTarget.PROJECT });
|
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" }],
|
metroCities: [{ id: "city_ber", name: "Berlin", countryId: "country_de" }],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const caller = createCallerFactory(countryRouter)(createProtectedContext({
|
const caller = createCallerFactory(countryRouter)(
|
||||||
country: {
|
createProtectedContext({
|
||||||
findMany,
|
country: {
|
||||||
},
|
findMany,
|
||||||
}));
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await caller.list({ isActive: true });
|
const result = await caller.list({ isActive: true });
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(findMany).toHaveBeenCalledWith(expect.objectContaining({
|
expect(findMany).toHaveBeenCalledWith(
|
||||||
where: { isActive: true },
|
expect.objectContaining({
|
||||||
include: { metroCities: { orderBy: { name: "asc" } } },
|
where: { isActive: true },
|
||||||
orderBy: { name: "asc" },
|
include: { metroCities: { orderBy: { name: "asc" } } },
|
||||||
}));
|
orderBy: { name: "asc" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps minimal country lookups available to authenticated users", async () => {
|
it("keeps minimal country lookups available to authenticated users", async () => {
|
||||||
@@ -278,12 +312,14 @@ describe("master-data router authorization", () => {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
dailyWorkingHours: 8,
|
dailyWorkingHours: 8,
|
||||||
});
|
});
|
||||||
const caller = createCallerFactory(countryRouter)(createProtectedContext({
|
const caller = createCallerFactory(countryRouter)(
|
||||||
country: {
|
createProtectedContext({
|
||||||
findUnique: vi.fn().mockResolvedValue(null),
|
country: {
|
||||||
findFirst,
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
},
|
findFirst,
|
||||||
}));
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await caller.resolveByIdentifier({ identifier: "de" });
|
const result = await caller.resolveByIdentifier({ identifier: "de" });
|
||||||
|
|
||||||
@@ -303,11 +339,13 @@ describe("master-data router authorization", () => {
|
|||||||
name: "Munich",
|
name: "Munich",
|
||||||
countryId: "country_de",
|
countryId: "country_de",
|
||||||
});
|
});
|
||||||
const caller = createCallerFactory(countryRouter)(createProtectedContext({
|
const caller = createCallerFactory(countryRouter)(
|
||||||
metroCity: {
|
createProtectedContext({
|
||||||
findUnique,
|
metroCity: {
|
||||||
},
|
findUnique,
|
||||||
}));
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await caller.getCityById({ id: "city_muc" });
|
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 () => {
|
it("requires resource overview access for detailed country reads with resource counts", async () => {
|
||||||
const caller = createCallerFactory(countryRouter)(createProtectedContext({
|
const caller = createCallerFactory(countryRouter)(
|
||||||
country: {
|
createProtectedContext({
|
||||||
findFirst: vi.fn(),
|
country: {
|
||||||
findUnique: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
},
|
findUnique: vi.fn(),
|
||||||
}));
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.getByIdentifier({ identifier: "DE" })).rejects.toMatchObject({
|
await expect(caller.getByIdentifier({ identifier: "DE" })).rejects.toMatchObject({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
@@ -351,24 +391,31 @@ describe("master-data router authorization", () => {
|
|||||||
metroCities: [{ id: "city_muc", name: "Munich", countryId: "country_de" }],
|
metroCities: [{ id: "city_muc", name: "Munich", countryId: "country_de" }],
|
||||||
_count: { resources: 12 },
|
_count: { resources: 12 },
|
||||||
});
|
});
|
||||||
const caller = createCallerFactory(countryRouter)(createProtectedContext({
|
const caller = createCallerFactory(countryRouter)(
|
||||||
country: {
|
createProtectedContext(
|
||||||
findUnique: vi.fn().mockResolvedValue(null),
|
{
|
||||||
findFirst,
|
country: {
|
||||||
},
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
}, {
|
findFirst,
|
||||||
granted: [PermissionKey.VIEW_ALL_RESOURCES],
|
},
|
||||||
}));
|
},
|
||||||
|
{
|
||||||
|
granted: [PermissionKey.VIEW_ALL_RESOURCES],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await caller.getByIdentifier({ identifier: "DE" });
|
const result = await caller.getByIdentifier({ identifier: "DE" });
|
||||||
|
|
||||||
expect(result._count.resources).toBe(12);
|
expect(result._count.resources).toBe(12);
|
||||||
expect(findFirst).toHaveBeenCalledWith(expect.objectContaining({
|
expect(findFirst).toHaveBeenCalledWith(
|
||||||
include: expect.objectContaining({
|
expect.objectContaining({
|
||||||
metroCities: expect.any(Object),
|
include: expect.objectContaining({
|
||||||
_count: expect.any(Object),
|
metroCities: expect.any(Object),
|
||||||
|
_count: expect.any(Object),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}));
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows detailed country reads for users with manage-resources access", async () => {
|
it("allows detailed country reads for users with manage-resources access", async () => {
|
||||||
@@ -382,13 +429,18 @@ describe("master-data router authorization", () => {
|
|||||||
metroCities: [],
|
metroCities: [],
|
||||||
_count: { resources: 4 },
|
_count: { resources: 4 },
|
||||||
});
|
});
|
||||||
const caller = createCallerFactory(countryRouter)(createProtectedContext({
|
const caller = createCallerFactory(countryRouter)(
|
||||||
country: {
|
createProtectedContext(
|
||||||
findUnique,
|
{
|
||||||
},
|
country: {
|
||||||
}, {
|
findUnique,
|
||||||
granted: [PermissionKey.MANAGE_RESOURCES],
|
},
|
||||||
}));
|
},
|
||||||
|
{
|
||||||
|
granted: [PermissionKey.MANAGE_RESOURCES],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await caller.getById({ id: "country_de" });
|
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 () => {
|
it("keeps minimal org-unit lookups available to authenticated users", async () => {
|
||||||
const findFirst = vi.fn()
|
const findFirst = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({
|
||||||
.mockResolvedValueOnce(null)
|
id: "ou_1",
|
||||||
.mockResolvedValueOnce({
|
name: "Delivery",
|
||||||
id: "ou_1",
|
shortName: "DEL",
|
||||||
name: "Delivery",
|
level: 5,
|
||||||
shortName: "DEL",
|
isActive: true,
|
||||||
level: 5,
|
});
|
||||||
isActive: true,
|
const caller = createCallerFactory(orgUnitRouter)(
|
||||||
});
|
createProtectedContext({
|
||||||
const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({
|
orgUnit: {
|
||||||
orgUnit: {
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
findUnique: vi.fn().mockResolvedValue(null),
|
findFirst,
|
||||||
findFirst,
|
},
|
||||||
},
|
}),
|
||||||
}));
|
);
|
||||||
|
|
||||||
const result = await caller.resolveByIdentifier({ identifier: "DEL" });
|
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 () => {
|
it("requires resource overview access for org-unit list and tree reads", async () => {
|
||||||
const findMany = vi.fn();
|
const findMany = vi.fn();
|
||||||
const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({
|
const caller = createCallerFactory(orgUnitRouter)(
|
||||||
orgUnit: {
|
createProtectedContext({
|
||||||
findMany,
|
orgUnit: {
|
||||||
},
|
findMany,
|
||||||
}));
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.list({ level: 5, isActive: true })).rejects.toMatchObject({
|
await expect(caller.list({ level: 5, isActive: true })).rejects.toMatchObject({
|
||||||
code: "FORBIDDEN",
|
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 () => {
|
it("requires resource overview access for detailed org-unit reads with staffing counts", async () => {
|
||||||
const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({
|
const caller = createCallerFactory(orgUnitRouter)(
|
||||||
orgUnit: {
|
createProtectedContext({
|
||||||
findFirst: vi.fn(),
|
orgUnit: {
|
||||||
findUnique: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
},
|
findUnique: vi.fn(),
|
||||||
}));
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.getByIdentifier({ identifier: "Delivery" })).rejects.toMatchObject({
|
await expect(caller.getByIdentifier({ identifier: "Delivery" })).rejects.toMatchObject({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
@@ -473,21 +529,28 @@ describe("master-data router authorization", () => {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
_count: { resources: 7 },
|
_count: { resources: 7 },
|
||||||
});
|
});
|
||||||
const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({
|
const caller = createCallerFactory(orgUnitRouter)(
|
||||||
orgUnit: {
|
createProtectedContext(
|
||||||
findUnique: vi.fn().mockResolvedValue(null),
|
{
|
||||||
findFirst,
|
orgUnit: {
|
||||||
},
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
}, {
|
findFirst,
|
||||||
granted: [PermissionKey.VIEW_ALL_RESOURCES],
|
},
|
||||||
}));
|
},
|
||||||
|
{
|
||||||
|
granted: [PermissionKey.VIEW_ALL_RESOURCES],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await caller.getByIdentifier({ identifier: "Delivery" });
|
const result = await caller.getByIdentifier({ identifier: "Delivery" });
|
||||||
|
|
||||||
expect(result._count.resources).toBe(7);
|
expect(result._count.resources).toBe(7);
|
||||||
expect(findFirst).toHaveBeenCalledWith(expect.objectContaining({
|
expect(findFirst).toHaveBeenCalledWith(
|
||||||
include: { _count: { select: { resources: true } } },
|
expect.objectContaining({
|
||||||
}));
|
include: { _count: { select: { resources: true } } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows org-unit list and tree reads for users with resource overview access", async () => {
|
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"),
|
updatedAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({
|
const caller = createCallerFactory(orgUnitRouter)(
|
||||||
orgUnit: {
|
createProtectedContext(
|
||||||
findMany: vi.fn()
|
{
|
||||||
.mockImplementationOnce(listFindMany)
|
orgUnit: {
|
||||||
.mockImplementationOnce(treeFindMany),
|
findMany: vi
|
||||||
},
|
.fn()
|
||||||
}, {
|
.mockImplementationOnce(listFindMany)
|
||||||
granted: [PermissionKey.MANAGE_RESOURCES],
|
.mockImplementationOnce(treeFindMany),
|
||||||
}));
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
granted: [PermissionKey.MANAGE_RESOURCES],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const listResult = await caller.list({ level: 5, isActive: true });
|
const listResult = await caller.list({ level: 5, isActive: true });
|
||||||
const treeResult = await caller.getTree({ 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 () => {
|
it("keeps minimal client lookups available to authenticated users", async () => {
|
||||||
const findUnique = vi.fn()
|
const findUnique = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({
|
||||||
.mockResolvedValueOnce(null)
|
id: "client_1",
|
||||||
.mockResolvedValueOnce({
|
name: "Acme Studios",
|
||||||
id: "client_1",
|
code: "ACME",
|
||||||
name: "Acme Studios",
|
parentId: null,
|
||||||
code: "ACME",
|
isActive: true,
|
||||||
parentId: null,
|
});
|
||||||
isActive: true,
|
const caller = createCallerFactory(clientRouter)(
|
||||||
});
|
createProtectedContext({
|
||||||
const caller = createCallerFactory(clientRouter)(createProtectedContext({
|
client: {
|
||||||
client: {
|
findUnique,
|
||||||
findUnique,
|
findFirst: vi.fn(),
|
||||||
findFirst: vi.fn(),
|
},
|
||||||
},
|
}),
|
||||||
}));
|
);
|
||||||
|
|
||||||
const result = await caller.resolveByIdentifier({ identifier: "ACME" });
|
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 () => {
|
it("requires planning read access for client list and tree reads", async () => {
|
||||||
const findMany = vi.fn();
|
const findMany = vi.fn();
|
||||||
const caller = createCallerFactory(clientRouter)(createProtectedContext({
|
const caller = createCallerFactory(clientRouter)(
|
||||||
client: {
|
createProtectedContext({
|
||||||
findMany,
|
client: {
|
||||||
},
|
findMany,
|
||||||
}));
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.list({ isActive: true })).rejects.toMatchObject({
|
await expect(caller.list({ isActive: true })).rejects.toMatchObject({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
@@ -597,12 +668,14 @@ describe("master-data router authorization", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("requires planning read access for detailed client reads with project counts", async () => {
|
it("requires planning read access for detailed client reads with project counts", async () => {
|
||||||
const caller = createCallerFactory(clientRouter)(createProtectedContext({
|
const caller = createCallerFactory(clientRouter)(
|
||||||
client: {
|
createProtectedContext({
|
||||||
findFirst: vi.fn(),
|
client: {
|
||||||
findUnique: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
},
|
findUnique: vi.fn(),
|
||||||
}));
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.getByIdentifier({ identifier: "Acme" })).rejects.toMatchObject({
|
await expect(caller.getByIdentifier({ identifier: "Acme" })).rejects.toMatchObject({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
@@ -642,7 +715,8 @@ describe("master-data router authorization", () => {
|
|||||||
updatedAt: new Date("2026-03-01T00:00:00.000Z"),
|
updatedAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const getByIdFindUnique = vi.fn()
|
const getByIdFindUnique = vi
|
||||||
|
.fn()
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
id: "client_1",
|
id: "client_1",
|
||||||
name: "Acme Studios",
|
name: "Acme Studios",
|
||||||
@@ -670,17 +744,23 @@ describe("master-data router authorization", () => {
|
|||||||
updatedAt: new Date("2026-03-01T00:00:00.000Z"),
|
updatedAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||||
_count: { children: 1, projects: 4 },
|
_count: { children: 1, projects: 4 },
|
||||||
});
|
});
|
||||||
const caller = createCallerFactory(clientRouter)(createProtectedContext({
|
const caller = createCallerFactory(clientRouter)(
|
||||||
client: {
|
createProtectedContext(
|
||||||
findMany: vi.fn()
|
{
|
||||||
.mockImplementationOnce(listFindMany)
|
client: {
|
||||||
.mockImplementationOnce(treeFindMany),
|
findMany: vi
|
||||||
findUnique: getByIdFindUnique,
|
.fn()
|
||||||
findFirst: getByIdentifierFindFirst,
|
.mockImplementationOnce(listFindMany)
|
||||||
},
|
.mockImplementationOnce(treeFindMany),
|
||||||
}, {
|
findUnique: getByIdFindUnique,
|
||||||
granted: [PermissionKey.VIEW_PLANNING],
|
findFirst: getByIdentifierFindFirst,
|
||||||
}));
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
granted: [PermissionKey.VIEW_PLANNING],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const listResult = await caller.list({ isActive: true, search: "Acme" });
|
const listResult = await caller.list({ isActive: true, search: "Acme" });
|
||||||
const treeResult = await caller.getTree({ isActive: true });
|
const treeResult = await caller.getTree({ isActive: true });
|
||||||
@@ -720,21 +800,25 @@ describe("master-data router authorization", () => {
|
|||||||
_count: { select: { projects: true, children: true } },
|
_count: { select: { projects: true, children: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(getByIdentifierFindFirst).toHaveBeenCalledWith(expect.objectContaining({
|
expect(getByIdentifierFindFirst).toHaveBeenCalledWith(
|
||||||
where: { name: { equals: "Acme Studios", mode: "insensitive" } },
|
expect.objectContaining({
|
||||||
include: { _count: { select: { projects: true, children: true } } },
|
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 () => {
|
it("requires planning read access for utilization-category reads with project counts", async () => {
|
||||||
const listFindMany = vi.fn();
|
const listFindMany = vi.fn();
|
||||||
const getByIdFindUnique = vi.fn();
|
const getByIdFindUnique = vi.fn();
|
||||||
const caller = createCallerFactory(utilizationCategoryRouter)(createProtectedContext({
|
const caller = createCallerFactory(utilizationCategoryRouter)(
|
||||||
utilizationCategory: {
|
createProtectedContext({
|
||||||
findMany: listFindMany,
|
utilizationCategory: {
|
||||||
findUnique: getByIdFindUnique,
|
findMany: listFindMany,
|
||||||
},
|
findUnique: getByIdFindUnique,
|
||||||
}));
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.list({ isActive: true })).rejects.toMatchObject({
|
await expect(caller.list({ isActive: true })).rejects.toMatchObject({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
@@ -771,14 +855,19 @@ describe("master-data router authorization", () => {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
_count: { projects: 12 },
|
_count: { projects: 12 },
|
||||||
});
|
});
|
||||||
const caller = createCallerFactory(utilizationCategoryRouter)(createProtectedContext({
|
const caller = createCallerFactory(utilizationCategoryRouter)(
|
||||||
utilizationCategory: {
|
createProtectedContext(
|
||||||
findMany: listFindMany,
|
{
|
||||||
findUnique: getByIdFindUnique,
|
utilizationCategory: {
|
||||||
},
|
findMany: listFindMany,
|
||||||
}, {
|
findUnique: getByIdFindUnique,
|
||||||
granted: [PermissionKey.VIEW_PLANNING],
|
},
|
||||||
}));
|
},
|
||||||
|
{
|
||||||
|
granted: [PermissionKey.VIEW_PLANNING],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const listResult = await caller.list({ isActive: true });
|
const listResult = await caller.list({ isActive: true });
|
||||||
const byIdResult = await caller.getById({ id: "util_chargeable" });
|
const byIdResult = await caller.getById({ id: "util_chargeable" });
|
||||||
@@ -788,6 +877,7 @@ describe("master-data router authorization", () => {
|
|||||||
expect(listFindMany).toHaveBeenCalledWith({
|
expect(listFindMany).toHaveBeenCalledWith({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
orderBy: { sortOrder: "asc" },
|
orderBy: { sortOrder: "asc" },
|
||||||
|
include: { _count: { select: { projects: true } } },
|
||||||
});
|
});
|
||||||
expect(getByIdFindUnique).toHaveBeenCalledWith({
|
expect(getByIdFindUnique).toHaveBeenCalledWith({
|
||||||
where: { id: "util_chargeable" },
|
where: { id: "util_chargeable" },
|
||||||
@@ -798,12 +888,14 @@ describe("master-data router authorization", () => {
|
|||||||
it("requires planning read access for management-level reads", async () => {
|
it("requires planning read access for management-level reads", async () => {
|
||||||
const listFindMany = vi.fn();
|
const listFindMany = vi.fn();
|
||||||
const getByIdFindUnique = vi.fn();
|
const getByIdFindUnique = vi.fn();
|
||||||
const caller = createCallerFactory(managementLevelRouter)(createProtectedContext({
|
const caller = createCallerFactory(managementLevelRouter)(
|
||||||
managementLevelGroup: {
|
createProtectedContext({
|
||||||
findMany: listFindMany,
|
managementLevelGroup: {
|
||||||
findUnique: getByIdFindUnique,
|
findMany: listFindMany,
|
||||||
},
|
findUnique: getByIdFindUnique,
|
||||||
}));
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.listGroups()).rejects.toMatchObject({
|
await expect(caller.listGroups()).rejects.toMatchObject({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
@@ -836,14 +928,19 @@ describe("master-data router authorization", () => {
|
|||||||
levels: [{ id: "mgmt_level_1", name: "Senior Team Lead" }],
|
levels: [{ id: "mgmt_level_1", name: "Senior Team Lead" }],
|
||||||
_count: { resources: 6 },
|
_count: { resources: 6 },
|
||||||
});
|
});
|
||||||
const caller = createCallerFactory(managementLevelRouter)(createProtectedContext({
|
const caller = createCallerFactory(managementLevelRouter)(
|
||||||
managementLevelGroup: {
|
createProtectedContext(
|
||||||
findMany: listFindMany,
|
{
|
||||||
findUnique: getByIdFindUnique,
|
managementLevelGroup: {
|
||||||
},
|
findMany: listFindMany,
|
||||||
}, {
|
findUnique: getByIdFindUnique,
|
||||||
granted: [PermissionKey.VIEW_PLANNING],
|
},
|
||||||
}));
|
},
|
||||||
|
{
|
||||||
|
granted: [PermissionKey.VIEW_PLANNING],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const listResult = await caller.listGroups();
|
const listResult = await caller.listGroups();
|
||||||
const detailResult = await caller.getGroupById({ id: "mgmt_group_1" });
|
const detailResult = await caller.getGroupById({ id: "mgmt_group_1" });
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ describe("utilization category router", () => {
|
|||||||
expect(findMany).toHaveBeenCalledWith({
|
expect(findMany).toHaveBeenCalledWith({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
orderBy: { sortOrder: "asc" },
|
orderBy: { sortOrder: "asc" },
|
||||||
|
include: { _count: { select: { projects: true } } },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface RateCardLookupParams {
|
export interface RateCardLookupParams {
|
||||||
clientId?: string | null;
|
clientId?: string | null | undefined;
|
||||||
chapter?: string | null;
|
chapter?: string | null | undefined;
|
||||||
roleId?: string | null;
|
roleId?: string | null | undefined;
|
||||||
seniority?: string | null;
|
seniority?: string | null | undefined;
|
||||||
location?: string | null;
|
location?: string | null | undefined;
|
||||||
workType?: string | null;
|
workType?: string | null | undefined;
|
||||||
effectiveDate?: Date | null;
|
effectiveDate?: Date | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RateCardLookupResult {
|
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<string, unknown> {
|
||||||
|
const where: Record<string, unknown> = {
|
||||||
|
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.
|
* Look up the best-matching rate card line for a given set of criteria.
|
||||||
* Returns null when no active rate card line matches.
|
* Returns null when no active rate card line matches.
|
||||||
@@ -59,120 +168,32 @@ export async function lookupRate(
|
|||||||
params: RateCardLookupParams,
|
params: RateCardLookupParams,
|
||||||
): Promise<RateCardLookupResult | null> {
|
): Promise<RateCardLookupResult | null> {
|
||||||
const effectiveDate = params.effectiveDate ?? new Date();
|
const effectiveDate = params.effectiveDate ?? new Date();
|
||||||
|
|
||||||
// Build rate card filter: active cards, within effective date range
|
|
||||||
const rateCardWhere: Record<string, unknown> = {
|
|
||||||
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({
|
const lines = (await db.rateCardLine.findMany({
|
||||||
where: {
|
where: { rateCard: buildRateCardWhere(params.clientId, effectiveDate) },
|
||||||
rateCard: rateCardWhere,
|
select: RATE_CARD_LINE_SELECT,
|
||||||
},
|
|
||||||
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 RateCardLineRow[];
|
})) as RateCardLineRow[];
|
||||||
|
|
||||||
if (lines.length === 0) return null;
|
return findBestMatch(lines, params);
|
||||||
|
}
|
||||||
// Score each line. Higher = better match.
|
|
||||||
type ScoredLine = { line: RateCardLineRow; score: number; mismatch: boolean };
|
/**
|
||||||
const scored: ScoredLine[] = lines.map((line) => {
|
* Batch-optimized rate lookup: loads rate card lines once, then scores
|
||||||
let score = 0;
|
* each param set against the cached lines. Use this when looking up rates
|
||||||
let mismatch = false;
|
* for multiple demand lines sharing the same clientId/effectiveDate.
|
||||||
|
*/
|
||||||
// Client specificity: client-specific cards get a large bonus
|
export async function lookupRatesBatch(
|
||||||
if (params.clientId && line.rateCard.clientId === params.clientId) {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
score += 100;
|
db: any,
|
||||||
} else if (params.clientId && line.rateCard.clientId != null) {
|
clientId: string | null | undefined,
|
||||||
// Different client entirely => disqualify
|
paramsList: RateCardLookupParams[],
|
||||||
mismatch = true;
|
): Promise<(RateCardLookupResult | null)[]> {
|
||||||
}
|
if (paramsList.length === 0) return [];
|
||||||
// Default card (null client) gets no bonus but is a valid fallback
|
|
||||||
|
const effectiveDate = paramsList[0]?.effectiveDate ?? new Date();
|
||||||
// Role match
|
const lines = (await db.rateCardLine.findMany({
|
||||||
if (params.roleId && line.roleId) {
|
where: { rateCard: buildRateCardWhere(clientId, effectiveDate) },
|
||||||
if (line.roleId === params.roleId) score += 16;
|
select: RATE_CARD_LINE_SELECT,
|
||||||
else mismatch = true;
|
})) as RateCardLineRow[];
|
||||||
}
|
|
||||||
|
return paramsList.map((params) => findBestMatch(lines, { ...params, clientId }));
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,138 +4,149 @@ import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } fro
|
|||||||
|
|
||||||
type ConfigReadmodelsDeps = {
|
type ConfigReadmodelsDeps = {
|
||||||
createManagementLevelCaller: (ctx: TRPCContext) => {
|
createManagementLevelCaller: (ctx: TRPCContext) => {
|
||||||
listGroups: () => Promise<Array<{
|
listGroups: () => Promise<
|
||||||
id: string;
|
Array<{
|
||||||
name: string;
|
|
||||||
targetPercentage: number | null;
|
|
||||||
levels: Array<{ id: string; name: string }>;
|
|
||||||
}>>;
|
|
||||||
};
|
|
||||||
createUtilizationCategoryCaller: (ctx: TRPCContext) => {
|
|
||||||
list: () => Promise<Array<{
|
|
||||||
id: string;
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
}>>;
|
|
||||||
getById: (params: { id: string }) => Promise<{
|
|
||||||
_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;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
shortCode: string;
|
targetPercentage: number | null;
|
||||||
} | 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) => {
|
createEffortRuleCaller: (ctx: TRPCContext) => {
|
||||||
list: () => Promise<Array<{
|
list: () => Promise<
|
||||||
name: string;
|
Array<{
|
||||||
isDefault: boolean;
|
name: string;
|
||||||
rules: Array<{
|
isDefault: boolean;
|
||||||
id: string;
|
rules: Array<{
|
||||||
description: string | null;
|
id: string;
|
||||||
scopeType: string;
|
description: string | null;
|
||||||
discipline: string;
|
scopeType: string;
|
||||||
chapter: string | null;
|
discipline: string;
|
||||||
unitMode: string;
|
chapter: string | null;
|
||||||
hoursPerUnit: number;
|
unitMode: string;
|
||||||
sortOrder: number;
|
hoursPerUnit: number;
|
||||||
}>;
|
sortOrder: number;
|
||||||
}>>;
|
}>;
|
||||||
|
}>
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
createExperienceMultiplierCaller: (ctx: TRPCContext) => {
|
createExperienceMultiplierCaller: (ctx: TRPCContext) => {
|
||||||
list: () => Promise<Array<{
|
list: () => Promise<
|
||||||
name: string;
|
Array<{
|
||||||
isDefault: boolean;
|
name: string;
|
||||||
rules: Array<{
|
isDefault: boolean;
|
||||||
id: string;
|
rules: Array<{
|
||||||
description: string | null;
|
id: string;
|
||||||
chapter: string | null;
|
description: string | null;
|
||||||
location: string | null;
|
chapter: string | null;
|
||||||
level: string | null;
|
location: string | null;
|
||||||
costMultiplier: number;
|
level: string | null;
|
||||||
billMultiplier: number;
|
costMultiplier: number;
|
||||||
shoringRatio: number | null;
|
billMultiplier: number;
|
||||||
additionalEffortRatio: number | null;
|
shoringRatio: number | null;
|
||||||
sortOrder: number;
|
additionalEffortRatio: number | null;
|
||||||
}>;
|
sortOrder: number;
|
||||||
}>>;
|
}>;
|
||||||
|
}>
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
|
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",
|
list_management_levels: {
|
||||||
function: {
|
requiresPlanningRead: true,
|
||||||
name: "list_management_levels",
|
},
|
||||||
description: "List management level groups and their levels with target percentages.",
|
list_utilization_categories: {
|
||||||
parameters: { type: "object", properties: {} },
|
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(
|
export function createConfigReadmodelExecutors(
|
||||||
deps: ConfigReadmodelsDeps,
|
deps: ConfigReadmodelsDeps,
|
||||||
@@ -155,19 +166,13 @@ export function createConfigReadmodelExecutors(
|
|||||||
async list_utilization_categories(_params: Record<string, never>, ctx: ToolContext) {
|
async list_utilization_categories(_params: Record<string, never>, ctx: ToolContext) {
|
||||||
const caller = deps.createUtilizationCategoryCaller(deps.createScopedCallerContext(ctx));
|
const caller = deps.createUtilizationCategoryCaller(deps.createScopedCallerContext(ctx));
|
||||||
const categories = await caller.list();
|
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,
|
id: category.id,
|
||||||
code: category.code,
|
code: category.code,
|
||||||
name: category.name,
|
name: category.name,
|
||||||
description: category.description,
|
description: category.description,
|
||||||
projectCount,
|
projectCount: category._count.projects,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -198,41 +203,45 @@ export function createConfigReadmodelExecutors(
|
|||||||
async list_effort_rules(_params: Record<string, never>, ctx: ToolContext) {
|
async list_effort_rules(_params: Record<string, never>, ctx: ToolContext) {
|
||||||
const caller = deps.createEffortRuleCaller(deps.createScopedCallerContext(ctx));
|
const caller = deps.createEffortRuleCaller(deps.createScopedCallerContext(ctx));
|
||||||
const ruleSets = await caller.list();
|
const ruleSets = await caller.list();
|
||||||
return ruleSets.flatMap((ruleSet) => ruleSet.rules.map((rule) => ({
|
return ruleSets.flatMap((ruleSet) =>
|
||||||
id: rule.id,
|
ruleSet.rules.map((rule) => ({
|
||||||
description: rule.description,
|
id: rule.id,
|
||||||
scopeType: rule.scopeType,
|
description: rule.description,
|
||||||
discipline: rule.discipline,
|
scopeType: rule.scopeType,
|
||||||
chapter: rule.chapter,
|
discipline: rule.discipline,
|
||||||
unitMode: rule.unitMode,
|
chapter: rule.chapter,
|
||||||
hoursPerUnit: rule.hoursPerUnit,
|
unitMode: rule.unitMode,
|
||||||
sortOrder: rule.sortOrder,
|
hoursPerUnit: rule.hoursPerUnit,
|
||||||
ruleSet: {
|
sortOrder: rule.sortOrder,
|
||||||
name: ruleSet.name,
|
ruleSet: {
|
||||||
isDefault: ruleSet.isDefault,
|
name: ruleSet.name,
|
||||||
},
|
isDefault: ruleSet.isDefault,
|
||||||
})));
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
async list_experience_multipliers(_params: Record<string, never>, ctx: ToolContext) {
|
async list_experience_multipliers(_params: Record<string, never>, ctx: ToolContext) {
|
||||||
const caller = deps.createExperienceMultiplierCaller(deps.createScopedCallerContext(ctx));
|
const caller = deps.createExperienceMultiplierCaller(deps.createScopedCallerContext(ctx));
|
||||||
const multiplierSets = await caller.list();
|
const multiplierSets = await caller.list();
|
||||||
return multiplierSets.flatMap((multiplierSet) => multiplierSet.rules.map((rule) => ({
|
return multiplierSets.flatMap((multiplierSet) =>
|
||||||
id: rule.id,
|
multiplierSet.rules.map((rule) => ({
|
||||||
description: rule.description,
|
id: rule.id,
|
||||||
chapter: rule.chapter,
|
description: rule.description,
|
||||||
location: rule.location,
|
chapter: rule.chapter,
|
||||||
level: rule.level,
|
location: rule.location,
|
||||||
costMultiplier: rule.costMultiplier,
|
level: rule.level,
|
||||||
billMultiplier: rule.billMultiplier,
|
costMultiplier: rule.costMultiplier,
|
||||||
shoringRatio: rule.shoringRatio,
|
billMultiplier: rule.billMultiplier,
|
||||||
additionalEffortRatio: rule.additionalEffortRatio,
|
shoringRatio: rule.shoringRatio,
|
||||||
sortOrder: rule.sortOrder,
|
additionalEffortRatio: rule.additionalEffortRatio,
|
||||||
multiplierSet: {
|
sortOrder: rule.sortOrder,
|
||||||
name: multiplierSet.name,
|
multiplierSet: {
|
||||||
isDefault: multiplierSet.isDefault,
|
name: multiplierSet.name,
|
||||||
},
|
isDefault: multiplierSet.isDefault,
|
||||||
})));
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { normalizeEstimateDemandLine, summarizeEstimateDemandLines } from "@capakraken/engine";
|
import { normalizeEstimateDemandLine, summarizeEstimateDemandLines } from "@capakraken/engine";
|
||||||
import { CreateEstimateSchema } from "@capakraken/shared";
|
import { CreateEstimateSchema } from "@capakraken/shared";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { lookupRate } from "../lib/rate-card-lookup.js";
|
import { lookupRatesBatch } from "../lib/rate-card-lookup.js";
|
||||||
|
|
||||||
function buildComputedMetrics(
|
function buildComputedMetrics(demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"]) {
|
||||||
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"],
|
|
||||||
) {
|
|
||||||
const summary = summarizeEstimateDemandLines(demandLines);
|
const summary = summarizeEstimateDemandLines(demandLines);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -62,7 +60,9 @@ function normalizeDemandLines<
|
|||||||
const snapshotsByResourceId = new Map(
|
const snapshotsByResourceId = new Map(
|
||||||
input.resourceSnapshots
|
input.resourceSnapshots
|
||||||
.filter(
|
.filter(
|
||||||
(snapshot): snapshot is (typeof input.resourceSnapshots)[number] & {
|
(
|
||||||
|
snapshot,
|
||||||
|
): snapshot is (typeof input.resourceSnapshots)[number] & {
|
||||||
resourceId: string;
|
resourceId: string;
|
||||||
} => typeof snapshot.resourceId === "string" && snapshot.resourceId.length > 0,
|
} => typeof snapshot.resourceId === "string" && snapshot.resourceId.length > 0,
|
||||||
)
|
)
|
||||||
@@ -71,8 +71,7 @@ function normalizeDemandLines<
|
|||||||
|
|
||||||
return input.demandLines.map((line) =>
|
return input.demandLines.map((line) =>
|
||||||
normalizeEstimateDemandLine(line, {
|
normalizeEstimateDemandLine(line, {
|
||||||
resourceSnapshot:
|
resourceSnapshot: line.resourceId != null ? snapshotsByResourceId.get(line.resourceId) : null,
|
||||||
line.resourceId != null ? snapshotsByResourceId.get(line.resourceId) : null,
|
|
||||||
defaultCurrency: baseCurrency,
|
defaultCurrency: baseCurrency,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -117,44 +116,60 @@ export async function autoFillDemandLineRates(
|
|||||||
clientId = project?.clientId ?? null;
|
clientId = project?.clientId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const autoFilledIndices: number[] = [];
|
// Identify which lines need auto-fill and collect their lookup params
|
||||||
const enriched = await Promise.all(
|
const needsLookup: {
|
||||||
demandLines.map(async (line, index) => {
|
index: number;
|
||||||
const isDefaultRate = line.costRateCents === 0 && line.billRateCents === 0;
|
params: { chapter: string | null; roleId: string | null };
|
||||||
const hasExplicitSource = line.rateSource != null && line.rateSource.length > 0;
|
}[] = [];
|
||||||
if (!isDefaultRate || hasExplicitSource) {
|
for (let i = 0; i < demandLines.length; i++) {
|
||||||
return line;
|
const line = demandLines[i]!;
|
||||||
}
|
const isDefaultRate = line.costRateCents === 0 && line.billRateCents === 0;
|
||||||
|
const hasExplicitSource = line.rateSource != null && line.rateSource.length > 0;
|
||||||
const result = await lookupRate(db, {
|
if (isDefaultRate && !hasExplicitSource) {
|
||||||
clientId,
|
needsLookup.push({
|
||||||
chapter: line.chapter ?? null,
|
index: i,
|
||||||
roleId: line.roleId ?? null,
|
params: { chapter: line.chapter ?? null, roleId: line.roleId ?? null },
|
||||||
});
|
});
|
||||||
if (!result) {
|
}
|
||||||
return line;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
autoFilledIndices.push(index);
|
if (needsLookup.length === 0) {
|
||||||
const existingMetadata = (line.metadata ?? {}) as Record<string, unknown>;
|
return { demandLines, autoFilledIndices: [] };
|
||||||
return {
|
}
|
||||||
...line,
|
|
||||||
costRateCents: result.costRateCents,
|
// Single DB query for all rate card lines, scored locally per demand line
|
||||||
billRateCents: result.billRateCents,
|
const results = await lookupRatesBatch(
|
||||||
currency: result.currency,
|
db,
|
||||||
rateSource: `rate-card:${result.rateCardId}`,
|
clientId,
|
||||||
metadata: {
|
needsLookup.map((entry) => entry.params),
|
||||||
...existingMetadata,
|
|
||||||
autoAppliedRateCard: {
|
|
||||||
rateCardId: result.rateCardId,
|
|
||||||
rateCardLineId: result.rateCardLineId,
|
|
||||||
rateCardName: result.rateCardName,
|
|
||||||
appliedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
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 };
|
return { demandLines: enriched, autoFilledIndices };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
const overlays = await Promise.all(
|
||||||
resources.map(async (resource) => {
|
[...locationGroups.values()].map(async ({ locationResource, resourceIds }) => {
|
||||||
const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), {
|
const holidays = await getResolvedCalendarHolidays(resolverDb, {
|
||||||
periodStart: input.startDate,
|
periodStart: input.startDate,
|
||||||
periodEnd: input.endDate,
|
periodEnd: input.endDate,
|
||||||
countryId: resource.countryId,
|
countryId: locationResource.countryId,
|
||||||
countryCode: resource.country?.code ?? null,
|
countryCode: locationResource.country?.code ?? null,
|
||||||
federalState: resource.federalState,
|
federalState: locationResource.federalState,
|
||||||
metroCityId: resource.metroCityId,
|
metroCityId: locationResource.metroCityId,
|
||||||
metroCityName: resource.metroCity?.name ?? null,
|
metroCityName: locationResource.metroCity?.name ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return holidays.map((holiday) => {
|
return resourceIds.flatMap((resourceId) => {
|
||||||
const holidayDate = new Date(`${holiday.date}T00:00:00.000Z`);
|
const resource = resources.find((r) => r.id === resourceId)!;
|
||||||
return {
|
return holidays.map((holiday) => {
|
||||||
id: `calendar-holiday:${resource.id}:${holiday.date}`,
|
const holidayDate = new Date(`${holiday.date}T00:00:00.000Z`);
|
||||||
resourceId: resource.id,
|
return {
|
||||||
type: VacationType.PUBLIC_HOLIDAY,
|
id: `calendar-holiday:${resourceId}:${holiday.date}`,
|
||||||
status: "APPROVED" as const,
|
resourceId,
|
||||||
startDate: holidayDate,
|
type: VacationType.PUBLIC_HOLIDAY,
|
||||||
endDate: holidayDate,
|
status: "APPROVED" as const,
|
||||||
note: holiday.name,
|
startDate: holidayDate,
|
||||||
scope: holiday.scope,
|
endDate: holidayDate,
|
||||||
calendarName: holiday.calendarName,
|
note: holiday.name,
|
||||||
sourceType: holiday.sourceType,
|
scope: holiday.scope,
|
||||||
countryCode: resource.country?.code ?? null,
|
calendarName: holiday.calendarName,
|
||||||
countryName: resource.country?.name ?? null,
|
sourceType: holiday.sourceType,
|
||||||
federalState: resource.federalState ?? null,
|
countryCode: resource.country?.code ?? null,
|
||||||
metroCityName: resource.metroCity?.name ?? null,
|
countryName: resource.country?.name ?? null,
|
||||||
};
|
federalState: resource.federalState ?? null,
|
||||||
|
metroCityName: resource.metroCity?.name ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import {
|
|||||||
unsetDefaultUtilizationCategory,
|
unsetDefaultUtilizationCategory,
|
||||||
} from "./utilization-category-support.js";
|
} from "./utilization-category-support.js";
|
||||||
|
|
||||||
export const UtilizationCategoryListInputSchema = z.object({
|
export const UtilizationCategoryListInputSchema = z
|
||||||
isActive: z.boolean().optional(),
|
.object({
|
||||||
}).optional();
|
isActive: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
export const UtilizationCategoryByIdInputSchema = z.object({
|
export const UtilizationCategoryByIdInputSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -38,6 +40,7 @@ export async function listUtilizationCategories(
|
|||||||
return ctx.db.utilizationCategory.findMany({
|
return ctx.db.utilizationCategory.findMany({
|
||||||
where: buildUtilizationCategoryListWhere(input ?? {}),
|
where: buildUtilizationCategoryListWhere(input ?? {}),
|
||||||
orderBy: { sortOrder: "asc" },
|
orderBy: { sortOrder: "asc" },
|
||||||
|
include: { _count: { select: { projects: true } } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user