From b57f7e6d2eb154e430adfaa3d7cdbd92f19cae6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 13:29:27 +0200 Subject: [PATCH] refactor(api): extract rate card write support --- .../__tests__/rate-card-write-support.test.ts | 100 +++++++++++++ .../api/src/router/rate-card-write-support.ts | 136 ++++++++++++++++++ packages/api/src/router/rate-card.ts | 109 +++----------- 3 files changed, 252 insertions(+), 93 deletions(-) create mode 100644 packages/api/src/__tests__/rate-card-write-support.test.ts create mode 100644 packages/api/src/router/rate-card-write-support.ts diff --git a/packages/api/src/__tests__/rate-card-write-support.test.ts b/packages/api/src/__tests__/rate-card-write-support.test.ts new file mode 100644 index 0000000..c23416e --- /dev/null +++ b/packages/api/src/__tests__/rate-card-write-support.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { + buildRateCardCreateData, + buildRateCardLineCreateData, + buildRateCardLineUpdateData, + buildRateCardListWhere, + buildRateCardUpdateData, +} from "../router/rate-card-write-support.js"; + +describe("rate card write support", () => { + it("builds rate card list filters including effective date windows", () => { + expect(buildRateCardListWhere({ + isActive: true, + clientId: "client_1", + search: "Standard", + effectiveAt: new Date("2026-05-15T00:00:00.000Z"), + })).toEqual({ + isActive: true, + clientId: "client_1", + name: { contains: "Standard", mode: "insensitive" }, + OR: [ + { effectiveFrom: null }, + { effectiveFrom: { lte: new Date("2026-05-15T00:00:00.000Z") } }, + ], + AND: [ + { + OR: [ + { effectiveTo: null }, + { effectiveTo: { gte: new Date("2026-05-15T00:00:00.000Z") } }, + ], + }, + ], + }); + }); + + it("builds nested create data for a rate card and its lines", () => { + expect(buildRateCardCreateData({ + name: "Q1 2026", + currency: "EUR", + effectiveFrom: new Date("2026-01-01T00:00:00.000Z"), + clientId: "client_1", + lines: [{ + roleId: "role_1", + chapter: "Delivery", + costRateCents: 9500, + billRateCents: 14000, + attributes: { level: "senior" }, + }], + })).toEqual({ + name: "Q1 2026", + currency: "EUR", + effectiveFrom: new Date("2026-01-01T00:00:00.000Z"), + clientId: "client_1", + lines: { + create: [{ + roleId: "role_1", + chapter: "Delivery", + costRateCents: 9500, + billRateCents: 14000, + attributes: { level: "senior" }, + }], + }, + }); + }); + + it("builds standalone line create data with rate card id", () => { + expect(buildRateCardLineCreateData("rc_1", { + chapter: "Delivery", + costRateCents: 8000, + attributes: {}, + })).toEqual({ + rateCardId: "rc_1", + chapter: "Delivery", + costRateCents: 8000, + attributes: {}, + }); + }); + + it("builds sparse rate card updates", () => { + expect(buildRateCardUpdateData({ + name: "Updated 2026", + isActive: false, + })).toEqual({ + name: "Updated 2026", + isActive: false, + }); + }); + + it("builds line updates including role disconnect semantics", () => { + expect(buildRateCardLineUpdateData({ + roleId: null, + costRateCents: 12345, + attributes: { region: "de" }, + })).toEqual({ + role: { disconnect: true }, + costRateCents: 12345, + attributes: { region: "de" }, + }); + }); +}); diff --git a/packages/api/src/router/rate-card-write-support.ts b/packages/api/src/router/rate-card-write-support.ts new file mode 100644 index 0000000..026f372 --- /dev/null +++ b/packages/api/src/router/rate-card-write-support.ts @@ -0,0 +1,136 @@ +import type { Prisma } from "@capakraken/db"; +import { + CreateRateCardLineSchema, + CreateRateCardSchema, + UpdateRateCardLineSchema, + UpdateRateCardSchema, +} from "@capakraken/shared"; +import { z } from "zod"; + +type RateCardListInput = { + isActive?: boolean | undefined; + search?: string | undefined; + clientId?: string | undefined; + effectiveAt?: Date | undefined; +}; + +type CreateRateCardInput = z.infer; +type CreateRateCardLineInput = z.infer; +type UpdateRateCardInput = z.infer; +type UpdateRateCardLineInput = z.infer; + +function buildRateCardLineCoreData( + line: CreateRateCardLineInput, +) { + return { + ...(line.roleId !== undefined ? { roleId: line.roleId } : {}), + ...(line.chapter !== undefined ? { chapter: line.chapter } : {}), + ...(line.location !== undefined ? { location: line.location } : {}), + ...(line.seniority !== undefined ? { seniority: line.seniority } : {}), + ...(line.workType !== undefined ? { workType: line.workType } : {}), + ...(line.serviceGroup !== undefined ? { serviceGroup: line.serviceGroup } : {}), + costRateCents: line.costRateCents, + ...(line.billRateCents !== undefined ? { billRateCents: line.billRateCents } : {}), + ...(line.machineRateCents !== undefined ? { machineRateCents: line.machineRateCents } : {}), + attributes: line.attributes as Prisma.InputJsonValue, + }; +} + +export function buildRateCardListWhere( + input?: RateCardListInput, +): Prisma.RateCardWhereInput { + return { + ...(input?.isActive !== undefined ? { isActive: input.isActive } : {}), + ...(input?.clientId !== undefined ? { clientId: input.clientId } : {}), + ...(input?.search + ? { name: { contains: input.search, mode: "insensitive" } } + : {}), + ...(input?.effectiveAt + ? { + OR: [ + { effectiveFrom: null }, + { effectiveFrom: { lte: input.effectiveAt } }, + ], + AND: [ + { + OR: [ + { effectiveTo: null }, + { effectiveTo: { gte: input.effectiveAt } }, + ], + }, + ], + } + : {}), + }; +} + +export function buildRateCardCreateData( + input: CreateRateCardInput, +): Prisma.RateCardUncheckedCreateInput { + const { lines, ...cardData } = input; + + return { + name: cardData.name, + currency: cardData.currency, + ...(cardData.effectiveFrom !== undefined ? { effectiveFrom: cardData.effectiveFrom } : {}), + ...(cardData.effectiveTo !== undefined ? { effectiveTo: cardData.effectiveTo } : {}), + ...(cardData.source !== undefined ? { source: cardData.source } : {}), + ...(cardData.clientId !== undefined ? { clientId: cardData.clientId } : {}), + lines: { + create: lines.map((line) => buildRateCardNestedLineCreateData(line)), + }, + } as Prisma.RateCardUncheckedCreateInput; +} + +export function buildRateCardUpdateData( + input: UpdateRateCardInput, +): Prisma.RateCardUncheckedUpdateInput { + return { + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.currency !== undefined ? { currency: input.currency } : {}), + ...(input.effectiveFrom !== undefined ? { effectiveFrom: input.effectiveFrom } : {}), + ...(input.effectiveTo !== undefined ? { effectiveTo: input.effectiveTo } : {}), + ...(input.source !== undefined ? { source: input.source } : {}), + ...(input.clientId !== undefined ? { clientId: input.clientId } : {}), + ...(input.isActive !== undefined ? { isActive: input.isActive } : {}), + }; +} + +export function buildRateCardNestedLineCreateData( + line: CreateRateCardLineInput, +): Prisma.RateCardLineUncheckedCreateWithoutRateCardInput { + return buildRateCardLineCoreData(line) as Prisma.RateCardLineUncheckedCreateWithoutRateCardInput; +} + +export function buildRateCardLineCreateData( + rateCardId: string, + line: CreateRateCardLineInput, +): Prisma.RateCardLineUncheckedCreateInput { + return { + rateCardId, + ...buildRateCardLineCoreData(line), + } as Prisma.RateCardLineUncheckedCreateInput; +} + +export function buildRateCardLineUpdateData( + input: UpdateRateCardLineInput, +): Prisma.RateCardLineUpdateInput { + const updateData: Prisma.RateCardLineUpdateInput = {}; + + if (input.roleId !== undefined) { + updateData.role = input.roleId + ? { connect: { id: input.roleId } } + : { disconnect: true }; + } + if (input.chapter !== undefined) updateData.chapter = input.chapter; + if (input.location !== undefined) updateData.location = input.location; + if (input.seniority !== undefined) updateData.seniority = input.seniority; + if (input.workType !== undefined) updateData.workType = input.workType; + if (input.serviceGroup !== undefined) updateData.serviceGroup = input.serviceGroup; + if (input.costRateCents !== undefined) updateData.costRateCents = input.costRateCents; + if (input.billRateCents !== undefined) updateData.billRateCents = input.billRateCents; + if (input.machineRateCents !== undefined) updateData.machineRateCents = input.machineRateCents; + if (input.attributes !== undefined) updateData.attributes = input.attributes as Prisma.InputJsonValue; + + return updateData; +} diff --git a/packages/api/src/router/rate-card.ts b/packages/api/src/router/rate-card.ts index 6914b7e..6853bdb 100644 --- a/packages/api/src/router/rate-card.ts +++ b/packages/api/src/router/rate-card.ts @@ -1,4 +1,3 @@ -import type { Prisma } from "@capakraken/db"; import { CreateRateCardLineSchema, CreateRateCardSchema, @@ -15,6 +14,14 @@ import { resolveBestRate, resolveBestRateLineMatch, } from "./rate-card-read.js"; +import { + buildRateCardCreateData, + buildRateCardLineCreateData, + buildRateCardLineUpdateData, + buildRateCardListWhere, + buildRateCardUpdateData, + buildRateCardNestedLineCreateData, +} from "./rate-card-write-support.js"; export const rateCardRouter = createTRPCRouter({ list: controllerProcedure @@ -28,29 +35,7 @@ export const rateCardRouter = createTRPCRouter({ ) .query(async ({ ctx, input }) => { return ctx.db.rateCard.findMany({ - where: { - ...(input?.isActive !== undefined ? { isActive: input.isActive } : {}), - ...(input?.clientId !== undefined ? { clientId: input.clientId } : {}), - ...(input?.search - ? { name: { contains: input.search, mode: "insensitive" as const } } - : {}), - ...(input?.effectiveAt - ? { - OR: [ - { effectiveFrom: null }, - { effectiveFrom: { lte: input.effectiveAt } }, - ], - AND: [ - { - OR: [ - { effectiveTo: null }, - { effectiveTo: { gte: input.effectiveAt } }, - ], - }, - ], - } - : {}), - }, + where: buildRateCardListWhere(input), include: { _count: { select: { lines: true } }, client: { select: { id: true, name: true, code: true } }, @@ -99,31 +84,10 @@ export const rateCardRouter = createTRPCRouter({ create: managerProcedure .input(CreateRateCardSchema) .mutation(async ({ ctx, input }) => { - const { lines, ...cardData } = input; + const { lines, name, currency } = input; const rateCard = await ctx.db.rateCard.create({ - data: { - name: cardData.name, - currency: cardData.currency, - ...(cardData.effectiveFrom !== undefined ? { effectiveFrom: cardData.effectiveFrom } : {}), - ...(cardData.effectiveTo !== undefined ? { effectiveTo: cardData.effectiveTo } : {}), - ...(cardData.source !== undefined ? { source: cardData.source } : {}), - ...(cardData.clientId !== undefined ? { clientId: cardData.clientId } : {}), - lines: { - create: lines.map((line) => ({ - ...(line.roleId !== undefined ? { roleId: line.roleId } : {}), - ...(line.chapter !== undefined ? { chapter: line.chapter } : {}), - ...(line.location !== undefined ? { location: line.location } : {}), - ...(line.seniority !== undefined ? { seniority: line.seniority } : {}), - ...(line.workType !== undefined ? { workType: line.workType } : {}), - ...(line.serviceGroup !== undefined ? { serviceGroup: line.serviceGroup } : {}), - costRateCents: line.costRateCents, - ...(line.billRateCents !== undefined ? { billRateCents: line.billRateCents } : {}), - ...(line.machineRateCents !== undefined ? { machineRateCents: line.machineRateCents } : {}), - attributes: line.attributes as Prisma.InputJsonValue, - })), - }, - }, + data: buildRateCardCreateData(input), include: { lines: { select: rateCardLineSelect }, }, @@ -136,7 +100,7 @@ export const rateCardRouter = createTRPCRouter({ entityName: rateCard.name, action: "CREATE", userId: ctx.dbUser?.id, - after: { name: cardData.name, currency: cardData.currency, lineCount: lines.length }, + after: { name, currency, lineCount: lines.length }, source: "ui", }); @@ -153,15 +117,7 @@ export const rateCardRouter = createTRPCRouter({ const updated = await ctx.db.rateCard.update({ where: { id: input.id }, - data: { - ...(input.data.name !== undefined ? { name: input.data.name } : {}), - ...(input.data.currency !== undefined ? { currency: input.data.currency } : {}), - ...(input.data.effectiveFrom !== undefined ? { effectiveFrom: input.data.effectiveFrom } : {}), - ...(input.data.effectiveTo !== undefined ? { effectiveTo: input.data.effectiveTo } : {}), - ...(input.data.source !== undefined ? { source: input.data.source } : {}), - ...(input.data.clientId !== undefined ? { clientId: input.data.clientId } : {}), - ...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}), - }, + data: buildRateCardUpdateData(input.data), include: { _count: { select: { lines: true } }, client: { select: { id: true, name: true, code: true } }, @@ -216,19 +172,7 @@ export const rateCardRouter = createTRPCRouter({ ); const line = await ctx.db.rateCardLine.create({ - data: { - rateCardId: input.rateCardId, - ...(input.line.roleId !== undefined ? { roleId: input.line.roleId } : {}), - ...(input.line.chapter !== undefined ? { chapter: input.line.chapter } : {}), - ...(input.line.location !== undefined ? { location: input.line.location } : {}), - ...(input.line.seniority !== undefined ? { seniority: input.line.seniority } : {}), - ...(input.line.workType !== undefined ? { workType: input.line.workType } : {}), - ...(input.line.serviceGroup !== undefined ? { serviceGroup: input.line.serviceGroup } : {}), - costRateCents: input.line.costRateCents, - ...(input.line.billRateCents !== undefined ? { billRateCents: input.line.billRateCents } : {}), - ...(input.line.machineRateCents !== undefined ? { machineRateCents: input.line.machineRateCents } : {}), - attributes: input.line.attributes as Prisma.InputJsonValue, - }, + data: buildRateCardLineCreateData(input.rateCardId, input.line), select: rateCardLineSelect, }); @@ -254,21 +198,9 @@ export const rateCardRouter = createTRPCRouter({ "Rate card line", ); - const updateData: Prisma.RateCardLineUpdateInput = {}; - if (input.data.roleId !== undefined) updateData.role = input.data.roleId ? { connect: { id: input.data.roleId } } : { disconnect: true }; - if (input.data.chapter !== undefined) updateData.chapter = input.data.chapter; - if (input.data.location !== undefined) updateData.location = input.data.location; - if (input.data.seniority !== undefined) updateData.seniority = input.data.seniority; - if (input.data.workType !== undefined) updateData.workType = input.data.workType; - if (input.data.serviceGroup !== undefined) updateData.serviceGroup = input.data.serviceGroup; - if (input.data.costRateCents !== undefined) updateData.costRateCents = input.data.costRateCents; - if (input.data.billRateCents !== undefined) updateData.billRateCents = input.data.billRateCents; - if (input.data.machineRateCents !== undefined) updateData.machineRateCents = input.data.machineRateCents; - if (input.data.attributes !== undefined) updateData.attributes = input.data.attributes as Prisma.InputJsonValue; - const updated = await ctx.db.rateCardLine.update({ where: { id: input.lineId }, - data: updateData, + data: buildRateCardLineUpdateData(input.data), select: rateCardLineSelect, }); @@ -330,16 +262,7 @@ export const rateCardRouter = createTRPCRouter({ tx.rateCardLine.create({ data: { rateCardId: input.rateCardId, - ...(line.roleId !== undefined ? { roleId: line.roleId } : {}), - ...(line.chapter !== undefined ? { chapter: line.chapter } : {}), - ...(line.location !== undefined ? { location: line.location } : {}), - ...(line.seniority !== undefined ? { seniority: line.seniority } : {}), - ...(line.workType !== undefined ? { workType: line.workType } : {}), - ...(line.serviceGroup !== undefined ? { serviceGroup: line.serviceGroup } : {}), - costRateCents: line.costRateCents, - ...(line.billRateCents !== undefined ? { billRateCents: line.billRateCents } : {}), - ...(line.machineRateCents !== undefined ? { machineRateCents: line.machineRateCents } : {}), - attributes: line.attributes as Prisma.InputJsonValue, + ...buildRateCardNestedLineCreateData(line), }, select: rateCardLineSelect, }),