From c383a291e1f05fc505539a431dbffccec7884e04 Mon Sep 17 00:00:00 2001 From: flfeders Date: Wed, 9 Sep 2026 13:34:14 +0200 Subject: [PATCH] Kundeninventar-Nummern kollisionsfrei vergeben --- backend/src/routes/resources/main.ts | 20 +++++++- backend/src/utils/functions.ts | 34 ++++++++++--- backend/tests/numberRangeAvailability.test.ts | 50 +++++++++++++++++++ 3 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 backend/tests/numberRangeAvailability.test.ts diff --git a/backend/src/routes/resources/main.ts b/backend/src/routes/resources/main.ts index 9cefe5c..cffac5d 100644 --- a/backend/src/routes/resources/main.ts +++ b/backend/src/routes/resources/main.ts @@ -1035,7 +1035,25 @@ export default async function resourceRoutes(server: FastifyInstance) { if (config.numberRangeHolder && !body[config.numberRangeHolder]) { const numberRangeResource = resource === "members" ? "customers" : resource - const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource) + const numberRangeColumn = table[config.numberRangeHolder] + const tenantColumn = getTenantColumn(resource, table) + const result = await useNextNumberRangeNumber( + server, + req.user.tenant_id, + numberRangeResource, + async (candidate, tx) => { + const existing = await tx + .select({ id: table.id }) + .from(table) + .where(and( + eq(tenantColumn, req.user!.tenant_id), + eq(numberRangeColumn, candidate) + )) + .limit(1) + + return existing.length === 0 + } + ) createData[config.numberRangeHolder] = result.usedNumber } diff --git a/backend/src/utils/functions.ts b/backend/src/utils/functions.ts index 764fa48..5742aa2 100644 --- a/backend/src/utils/functions.ts +++ b/backend/src/utils/functions.ts @@ -10,7 +10,8 @@ import { eq, sql } from "drizzle-orm" export const useNextNumberRangeNumber = async ( server: FastifyInstance, tenantId: number, - numberRange: string + numberRange: string, + isAvailable: (candidate: string, tx: any) => Promise = async () => true ) => { const numberRangeFallbacks: Record = { costEstimates: "quotes", @@ -44,18 +45,39 @@ export const useNextNumberRangeNumber = async ( } const current = numberRanges[resolvedNumberRange] + let nextNumber = Number(current.nextNumber) - const usedNumber = - (current.prefix || "") + - current.nextNumber + - (current.suffix || "") + if (!Number.isFinite(nextNumber)) { + throw new Error(`Number range '${resolvedNumberRange}' has an invalid nextNumber`) + } + + let usedNumber = "" + const maxAttempts = 10000 + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const candidate = + (current.prefix || "") + + nextNumber + + (current.suffix || "") + + if (await isAvailable(candidate, tx)) { + usedNumber = candidate + break + } + + nextNumber++ + } + + if (!usedNumber) { + throw new Error(`Could not find an available number in range '${resolvedNumberRange}'`) + } const updatedRanges = { // @ts-ignore ...numberRanges, [resolvedNumberRange]: { ...current, - nextNumber: current.nextNumber + 1, + nextNumber: nextNumber + 1, }, } diff --git a/backend/tests/numberRangeAvailability.test.ts b/backend/tests/numberRangeAvailability.test.ts new file mode 100644 index 0000000..1e4319a --- /dev/null +++ b/backend/tests/numberRangeAvailability.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { useNextNumberRangeNumber } from "../src/utils/functions" + +test("number range skips identifiers that are already in use", async () => { + const tenant = { + id: 7, + numberRanges: { + customerinventoryitems: { + prefix: "KIA-", + suffix: "", + nextNumber: 1000, + }, + }, + } + let savedNumberRanges: any = null + + const tx = { + execute: async () => undefined, + select: () => ({ + from: () => ({ + where: async () => [tenant], + }), + }), + update: () => ({ + set: (data: any) => ({ + where: async () => { + savedNumberRanges = data.numberRanges + }, + }), + }), + } + const server = { + db: { + transaction: async (callback: (transaction: typeof tx) => Promise) => callback(tx), + }, + } as any + const occupied = new Set(["KIA-1000", "KIA-1001"]) + + const result = await useNextNumberRangeNumber( + server, + tenant.id, + "customerinventoryitems", + async (candidate) => !occupied.has(candidate) + ) + + assert.equal(result.usedNumber, "KIA-1002") + assert.equal(savedNumberRanges.customerinventoryitems.nextNumber, 1003) +})