fix(numbering): release project number on order delete + harden split tests + accurate settings preview

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 19:44:54 +02:00
parent 753c16423c
commit 113edd9a0d
4 changed files with 193 additions and 2 deletions

View File

@@ -1,24 +1,48 @@
import { describe, it, expect, afterEach } from "vitest";
import prisma from "../config/database";
import { createProject } from "../services/projects.service";
import { createOrder } from "../services/orders.service";
import {
createOrder,
createOrderFromQuotation,
deleteOrder,
} from "../services/orders.service";
import { previewProjectNumber } from "../services/numbering.service";
const YEAR = new Date().getFullYear();
const createdProjectIds: number[] = [];
const createdOrderIds: number[] = [];
const createdQuotationIds: number[] = [];
afterEach(async () => {
for (const id of createdProjectIds)
await prisma.projects.deleteMany({ where: { id } });
for (const id of createdOrderIds)
await prisma.orders.deleteMany({ where: { id } });
for (const id of createdQuotationIds)
await prisma.quotations.deleteMany({ where: { id } });
createdProjectIds.length = 0;
createdOrderIds.length = 0;
createdQuotationIds.length = 0;
// Orders and projects now draw from SEPARATE sequences — reset both.
await prisma.number_sequences.deleteMany({
where: { type: { in: ["shared", "project"] } },
});
});
/**
* Read the current `last_number` of a (type, current-year) sequence row, or 0
* when the row doesn't exist yet (the first allocation creates it at 1). Used to
* prove each generator advances its OWN row by exactly 1.
*/
async function seqLastNumber(type: "shared" | "project"): Promise<number> {
const row = await prisma.number_sequences.findFirst({
where: { type, year: YEAR },
select: { last_number: true },
});
return row?.last_number ?? 0;
}
const baseOrder = {
status: "prijata" as const,
currency: "CZK",
@@ -39,6 +63,13 @@ function failResult(res: unknown): never {
describe("createOrder (manual, optional linked project)", () => {
it("creates an order AND a project, each with its OWN number, linked by FK", async () => {
// Capture BOTH sequence counters before the create so we can prove each
// generator advanced its OWN row (not that the project consumed the shared
// counter). Format difference alone (OBJ-… vs 26730…) would let `not.toBe`
// pass even if the project drew from `shared` — the deltas catch that.
const sharedBefore = await seqLastNumber("shared");
const projectBefore = await seqLastNumber("project");
const res = await createOrder({
...baseOrder,
scope_title: "Ruční zakázka",
@@ -57,6 +88,15 @@ describe("createOrder (manual, optional linked project)", () => {
expect(project?.project_number).toBeTruthy();
expect(project?.project_number).not.toBe(res.order_number);
expect(project?.order_id).toBe(res.id);
// Sequence isolation: the order consumed exactly one `shared` number and
// the project consumed exactly one `project` number — each generator
// advanced its OWN row by 1. A regression where the project draws from the
// `shared` counter would bump `shared` by 2 and leave `project` at 0.
const sharedAfter = await seqLastNumber("shared");
const projectAfter = await seqLastNumber("project");
expect(sharedAfter - sharedBefore).toBe(1);
expect(projectAfter - projectBefore).toBe(1);
});
it("creates only the order when create_project=false", async () => {
@@ -67,6 +107,82 @@ describe("createOrder (manual, optional linked project)", () => {
});
});
describe("createOrderFromQuotation (auto-project gets its OWN number)", () => {
it("links a project whose number is from the project sequence, distinct from the order number", async () => {
// Seed a minimal quotation (no items/sections needed — createOrderFrom
// Quotation copies whatever is present and both arrays may be empty).
const quotation = await prisma.quotations.create({
data: {
quotation_number: `Q-FROMQ-${Date.now()}`,
status: "active",
currency: "CZK",
language: "cs",
vat_rate: 21,
apply_vat: true,
},
});
createdQuotationIds.push(quotation.id);
const res = await createOrderFromQuotation({
quotationId: quotation.id,
customerOrderNumber: "PO-FROMQ",
});
if (!("data" in res) || !res.data) failResult(res);
const orderId = res.data.order_id;
createdOrderIds.push(orderId);
const order = await prisma.orders.findUnique({ where: { id: orderId } });
const project = await prisma.projects.findFirst({
where: { order_id: orderId },
});
if (project) createdProjectIds.push(project.id);
// Primary flow: the auto-created project must get a non-empty number from
// the dedicated `project` sequence (code 73), DISTINCT from the order's
// shared number (code 71). Pre-split they shared one number.
expect(project).toBeTruthy();
expect(project?.project_number).toBeTruthy();
expect(order?.order_number).toBeTruthy();
expect(project?.project_number).not.toBe(order?.order_number);
expect(project?.order_id).toBe(orderId);
});
});
describe("deleteOrder releases the project number (release regression)", () => {
it("frees the auto-created project's number back to the project sequence", async () => {
// The project sequence is reset in afterEach, so the preview before the
// create is the number createOrder will consume for the project.
const previewBefore = await previewProjectNumber();
const res = await createOrder({
...baseOrder,
scope_title: "Smazatelná zakázka",
create_project: true,
});
if (!("id" in res)) failResult(res);
const orderId = res.id!;
expect(res.project_id).toBeTruthy();
const project = await prisma.projects.findUnique({
where: { id: res.project_id! },
});
expect(project?.project_number).toBe(previewBefore);
// After the order delete, the project row is gone AND its number must have
// been released (decremented) so the preview returns the freed number — not
// a permanent gap. This is the fix #1 regression guard.
const del = await deleteOrder(orderId);
if ("error" in del) failResult(del);
const gone = await prisma.projects.findUnique({
where: { id: res.project_id! },
});
expect(gone).toBeNull();
const previewAfter = await previewProjectNumber();
expect(previewAfter).toBe(previewBefore);
});
});
describe("separate order vs project sequences (coherence)", () => {
it("order → standalone project → order produce three distinct numbers (from separate sequences)", async () => {
const o1 = await createOrder({ ...baseOrder, create_project: true });