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) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-07 09:22:44 +02:00
parent c67dff4725
commit 1a0f57a247
2 changed files with 53 additions and 1 deletions

View File

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

View File

@@ -221,7 +221,17 @@ export async function previewOfferNumber(): Promise<string> {
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 });
}