From 1a0f57a2477c502b2197ea6c7bead931615e6d77 Mon Sep 17 00:00:00 2001 From: BOHA Date: Sun, 7 Jun 2026 09:22:44 +0200 Subject: [PATCH] fix(numbering): preview the next FREE offer number, not a stale one previewOfferNumber returned applyPattern(previewNextSequence) directly; when the number_sequences 'offer' counter lags behind real quotations, that raw next number is already taken, so the offer editor showed an already-used number (e.g. NA-2026-0001 when 0001/0002 exist). Mirror generateOfferNumber's collision loop in the preview (without consuming the sequence). +2 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/__tests__/numbering.test.ts | 42 +++++++++++++++++++++++++++++++ src/services/numbering.service.ts | 12 ++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/__tests__/numbering.test.ts b/src/__tests__/numbering.test.ts index dfd8444..9c9316f 100644 --- a/src/__tests__/numbering.test.ts +++ b/src/__tests__/numbering.test.ts @@ -4,6 +4,8 @@ import { generateOfferNumber, previewSharedNumber, isSharedNumberTaken, + previewOfferNumber, + isOfferNumberTaken, } from "../services/numbering.service"; import prisma from "../config/database"; @@ -90,3 +92,43 @@ describe("previewSharedNumber", () => { expect(await isSharedNumberTaken(second)).toBe(false); }); }); + +describe("previewOfferNumber", () => { + let createdQuotationId: number | null = null; + + beforeEach(async () => { + await prisma.number_sequences.deleteMany({ where: { type: "offer" } }); + }); + + afterEach(async () => { + if (createdQuotationId !== null) { + await prisma.quotations + .delete({ where: { id: createdQuotationId } }) + .catch(() => {}); + createdQuotationId = null; + } + await prisma.number_sequences.deleteMany({ where: { type: "offer" } }); + }); + + it("returns a number not already used by a quotation", async () => { + const preview = await previewOfferNumber(); + expect(typeof preview).toBe("string"); + expect(await isOfferNumberTaken(preview)).toBe(false); + }); + + it("skips a number already taken when the sequence counter lags behind", async () => { + // Fresh counter → preview points at the first sequence number; with the + // skip-taken logic it's guaranteed free, so we can claim it. + const first = await previewOfferNumber(); + const quotation = await prisma.quotations.create({ + data: { quotation_number: first }, + }); + createdQuotationId = quotation.id; + + // The counter still lags, but `first` is now taken — the preview must + // advance past it instead of re-offering it. + const second = await previewOfferNumber(); + expect(second).not.toBe(first); + expect(await isOfferNumberTaken(second)).toBe(false); + }); +}); diff --git a/src/services/numbering.service.ts b/src/services/numbering.service.ts index 5a07c23..8ac6ec6 100644 --- a/src/services/numbering.service.ts +++ b/src/services/numbering.service.ts @@ -221,7 +221,17 @@ export async function previewOfferNumber(): Promise { const prefix = settings?.quotation_prefix || "NA"; const year = new Date().getFullYear(); - const seq = await previewNextSequence("offer", year); + // Mirror generateOfferNumber's collision check (without consuming the + // sequence). The number_sequences counter can lag behind real data, so the + // raw next sequence may already be taken — advance past any number already + // used by a quotation so the preview matches what generateOfferNumber assigns + // instead of showing a stale, already-used number. + let seq = await previewNextSequence("offer", year); + for (let attempt = 0; attempt < 1000; attempt++) { + const number = applyPattern(pattern, { year, prefix, code: "", seq }); + if (!(await isOfferNumberTaken(number))) return number; + seq++; + } return applyPattern(pattern, { year, prefix, code: "", seq }); }