feat(orders): issued-order number sequence
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -224,12 +224,16 @@ describe("issued-order numbering", () => {
|
|||||||
|
|
||||||
it("release frees only the highest number", async () => {
|
it("release frees only the highest number", async () => {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const a = await generateIssuedOrderNumber(year);
|
await generateIssuedOrderNumber(year); // seq 1
|
||||||
const b = await generateIssuedOrderNumber(year);
|
await generateIssuedOrderNumber(year); // seq 2
|
||||||
await releaseIssuedOrderNumber(year, b.number);
|
const c = await generateIssuedOrderNumber(year); // seq 3 (current highest)
|
||||||
expect((await previewIssuedOrderNumber(year)).number).toBe(b.number);
|
const beforeRelease = (await previewIssuedOrderNumber(year)).number; // seq 4
|
||||||
await releaseIssuedOrderNumber(year, a.number); // not highest → no-op
|
// Releasing a number that is NOT the current highest is a no-op.
|
||||||
expect((await previewIssuedOrderNumber(year)).number).toBe(b.number);
|
await releaseIssuedOrderNumber(year, "00000000");
|
||||||
|
expect((await previewIssuedOrderNumber(year)).number).toBe(beforeRelease);
|
||||||
|
// Releasing the current highest reclaims it.
|
||||||
|
await releaseIssuedOrderNumber(year, c.number);
|
||||||
|
expect((await previewIssuedOrderNumber(year)).number).toBe(c.number);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|||||||
41
src/__tests__/issued-orders.test.ts
Normal file
41
src/__tests__/issued-orders.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import {
|
||||||
|
generateIssuedOrderNumber,
|
||||||
|
previewIssuedOrderNumber,
|
||||||
|
releaseIssuedOrderNumber,
|
||||||
|
} from "../services/numbering.service";
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await prisma.number_sequences.deleteMany({ where: { type: "issued_order" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("issued-order numbering", () => {
|
||||||
|
it("generates sequential numbers", async () => {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const a = await generateIssuedOrderNumber(year);
|
||||||
|
const b = await generateIssuedOrderNumber(year);
|
||||||
|
expect(Number(b.number)).toBe(Number(a.number) + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preview does not consume the sequence", async () => {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const p1 = await previewIssuedOrderNumber(year);
|
||||||
|
const p2 = await previewIssuedOrderNumber(year);
|
||||||
|
expect(p1.number).toBe(p2.number);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("release frees only the highest number", async () => {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
await generateIssuedOrderNumber(year); // seq 1
|
||||||
|
await generateIssuedOrderNumber(year); // seq 2
|
||||||
|
const c = await generateIssuedOrderNumber(year); // seq 3 (current highest)
|
||||||
|
const beforeRelease = (await previewIssuedOrderNumber(year)).number; // seq 4
|
||||||
|
// Releasing a number that is NOT the current highest is a no-op.
|
||||||
|
await releaseIssuedOrderNumber(year, "00000000");
|
||||||
|
expect((await previewIssuedOrderNumber(year)).number).toBe(beforeRelease);
|
||||||
|
// Releasing the current highest reclaims it.
|
||||||
|
await releaseIssuedOrderNumber(year, c.number);
|
||||||
|
expect((await previewIssuedOrderNumber(year)).number).toBe(c.number);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ type TxClient = Omit<
|
|||||||
const DEFAULT_OFFER_PATTERN = "{YYYY}/{PREFIX}/{NNN}";
|
const DEFAULT_OFFER_PATTERN = "{YYYY}/{PREFIX}/{NNN}";
|
||||||
const DEFAULT_ORDER_PATTERN = "{YY}{CODE}{NNNN}";
|
const DEFAULT_ORDER_PATTERN = "{YY}{CODE}{NNNN}";
|
||||||
const DEFAULT_INVOICE_PATTERN = "{YY}{CODE}{NNNN}";
|
const DEFAULT_INVOICE_PATTERN = "{YY}{CODE}{NNNN}";
|
||||||
|
const DEFAULT_ISSUED_ORDER_PATTERN = "{YY}{CODE}{NNNN}";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a numbering pattern template.
|
* Apply a numbering pattern template.
|
||||||
@@ -43,6 +44,8 @@ async function getSettings() {
|
|||||||
offer_number_pattern: true,
|
offer_number_pattern: true,
|
||||||
order_number_pattern: true,
|
order_number_pattern: true,
|
||||||
invoice_number_pattern: true,
|
invoice_number_pattern: true,
|
||||||
|
issued_order_number_pattern: true,
|
||||||
|
issued_order_type_code: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -150,13 +153,18 @@ async function releaseSequence(
|
|||||||
? settings?.order_number_pattern || DEFAULT_ORDER_PATTERN
|
? settings?.order_number_pattern || DEFAULT_ORDER_PATTERN
|
||||||
: type === "invoice"
|
: type === "invoice"
|
||||||
? settings?.invoice_number_pattern || DEFAULT_INVOICE_PATTERN
|
? settings?.invoice_number_pattern || DEFAULT_INVOICE_PATTERN
|
||||||
: settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN;
|
: type === "issued_order"
|
||||||
|
? settings?.issued_order_number_pattern ||
|
||||||
|
DEFAULT_ISSUED_ORDER_PATTERN
|
||||||
|
: settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN;
|
||||||
const code =
|
const code =
|
||||||
type === "shared"
|
type === "shared"
|
||||||
? settings?.order_type_code || "71"
|
? settings?.order_type_code || "71"
|
||||||
: type === "invoice"
|
: type === "invoice"
|
||||||
? settings?.invoice_type_code || "81"
|
? settings?.invoice_type_code || "81"
|
||||||
: "";
|
: type === "issued_order"
|
||||||
|
? settings?.issued_order_type_code || "72"
|
||||||
|
: "";
|
||||||
const prefix = type === "offer" ? settings?.quotation_prefix || "NA" : "";
|
const prefix = type === "offer" ? settings?.quotation_prefix || "NA" : "";
|
||||||
|
|
||||||
// Lock the sequence row for the duration of the decrement so a concurrent
|
// Lock the sequence row for the duration of the decrement so a concurrent
|
||||||
@@ -205,6 +213,14 @@ async function isInvoiceNumberTaken(number: string): Promise<boolean> {
|
|||||||
return !!existing;
|
return !!existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Verify an issued-order (PO) number is not already used. */
|
||||||
|
async function isIssuedOrderNumberTaken(number: string): Promise<boolean> {
|
||||||
|
const existing = await prisma.issued_orders.findFirst({
|
||||||
|
where: { po_number: number },
|
||||||
|
});
|
||||||
|
return !!existing;
|
||||||
|
}
|
||||||
|
|
||||||
/** Verify an offer/quotation number is not already used. */
|
/** Verify an offer/quotation number is not already used. */
|
||||||
export async function isOfferNumberTaken(number: string): Promise<boolean> {
|
export async function isOfferNumberTaken(number: string): Promise<boolean> {
|
||||||
const existing = await prisma.quotations.findFirst({
|
const existing = await prisma.quotations.findFirst({
|
||||||
@@ -355,6 +371,60 @@ export async function previewInvoiceNumber(
|
|||||||
return { number, next_number: number };
|
return { number, next_number: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Next issued-order (PO) number (consumes sequence).
|
||||||
|
* Verifies uniqueness against the issued_orders table; retries if taken.
|
||||||
|
* Pass `tx` when calling inside an existing Prisma transaction.
|
||||||
|
*/
|
||||||
|
export async function generateIssuedOrderNumber(
|
||||||
|
_year?: number,
|
||||||
|
tx?: TxClient,
|
||||||
|
): Promise<{ number: string; next_number: string }> {
|
||||||
|
const settings = await getSettings();
|
||||||
|
const pattern =
|
||||||
|
settings?.issued_order_number_pattern || DEFAULT_ISSUED_ORDER_PATTERN;
|
||||||
|
const code = settings?.issued_order_type_code || "72";
|
||||||
|
const year = _year || new Date().getFullYear();
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
|
const seq = await getNextSequence("issued_order", year, tx);
|
||||||
|
const number = applyPattern(pattern, { year, prefix: "", code, seq });
|
||||||
|
if (!(await isIssuedOrderNumberTaken(number))) {
|
||||||
|
return { number, next_number: number };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Nepodařilo se vygenerovat jedinečné číslo objednávky");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview next issued-order (PO) number (does NOT consume sequence).
|
||||||
|
*/
|
||||||
|
export async function previewIssuedOrderNumber(
|
||||||
|
_year?: number,
|
||||||
|
): Promise<{ number: string; next_number: string }> {
|
||||||
|
const settings = await getSettings();
|
||||||
|
const pattern =
|
||||||
|
settings?.issued_order_number_pattern || DEFAULT_ISSUED_ORDER_PATTERN;
|
||||||
|
const code = settings?.issued_order_type_code || "72";
|
||||||
|
const year = _year || new Date().getFullYear();
|
||||||
|
|
||||||
|
const seq = await previewNextSequence("issued_order", year);
|
||||||
|
const number = applyPattern(pattern, { year, prefix: "", code, seq });
|
||||||
|
return { number, next_number: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Release an issued-order number back to the pool (decrement if highest). */
|
||||||
|
export async function releaseIssuedOrderNumber(
|
||||||
|
year?: number,
|
||||||
|
deletedNumber?: string,
|
||||||
|
) {
|
||||||
|
await releaseSequence(
|
||||||
|
"issued_order",
|
||||||
|
year || new Date().getFullYear(),
|
||||||
|
deletedNumber,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Release an offer number back to the pool (decrement if highest). */
|
/** Release an offer number back to the pool (decrement if highest). */
|
||||||
export async function releaseOfferNumber(
|
export async function releaseOfferNumber(
|
||||||
year?: number,
|
year?: number,
|
||||||
|
|||||||
Reference in New Issue
Block a user