Kundeninventar-Nummern kollisionsfrei vergeben
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 45s
Build and Push Docker Images / build-frontend (push) Successful in 21s
Build and Push Docker Images / build-website (push) Successful in 21s
Build and Push Docker Images / build-central-services-api (push) Successful in 22s
Build and Push Docker Images / build-central-services-admin (push) Successful in 22s
Build and Push Docker Images / build-docs (push) Successful in 21s

This commit is contained in:
2026-09-09 13:34:14 +02:00
parent 0eadf42973
commit c383a291e1
3 changed files with 97 additions and 7 deletions

View File

@@ -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
}

View File

@@ -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<boolean> = async () => true
) => {
const numberRangeFallbacks: Record<string, string> = {
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,
},
}

View File

@@ -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<any>) => 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)
})