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:
@@ -1,24 +1,48 @@
|
|||||||
import { describe, it, expect, afterEach } from "vitest";
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
import { createProject } from "../services/projects.service";
|
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 createdProjectIds: number[] = [];
|
||||||
const createdOrderIds: number[] = [];
|
const createdOrderIds: number[] = [];
|
||||||
|
const createdQuotationIds: number[] = [];
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
for (const id of createdProjectIds)
|
for (const id of createdProjectIds)
|
||||||
await prisma.projects.deleteMany({ where: { id } });
|
await prisma.projects.deleteMany({ where: { id } });
|
||||||
for (const id of createdOrderIds)
|
for (const id of createdOrderIds)
|
||||||
await prisma.orders.deleteMany({ where: { id } });
|
await prisma.orders.deleteMany({ where: { id } });
|
||||||
|
for (const id of createdQuotationIds)
|
||||||
|
await prisma.quotations.deleteMany({ where: { id } });
|
||||||
createdProjectIds.length = 0;
|
createdProjectIds.length = 0;
|
||||||
createdOrderIds.length = 0;
|
createdOrderIds.length = 0;
|
||||||
|
createdQuotationIds.length = 0;
|
||||||
// Orders and projects now draw from SEPARATE sequences — reset both.
|
// Orders and projects now draw from SEPARATE sequences — reset both.
|
||||||
await prisma.number_sequences.deleteMany({
|
await prisma.number_sequences.deleteMany({
|
||||||
where: { type: { in: ["shared", "project"] } },
|
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 = {
|
const baseOrder = {
|
||||||
status: "prijata" as const,
|
status: "prijata" as const,
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
@@ -39,6 +63,13 @@ function failResult(res: unknown): never {
|
|||||||
|
|
||||||
describe("createOrder (manual, optional linked project)", () => {
|
describe("createOrder (manual, optional linked project)", () => {
|
||||||
it("creates an order AND a project, each with its OWN number, linked by FK", async () => {
|
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({
|
const res = await createOrder({
|
||||||
...baseOrder,
|
...baseOrder,
|
||||||
scope_title: "Ruční zakázka",
|
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).toBeTruthy();
|
||||||
expect(project?.project_number).not.toBe(res.order_number);
|
expect(project?.project_number).not.toBe(res.order_number);
|
||||||
expect(project?.order_id).toBe(res.id);
|
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 () => {
|
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)", () => {
|
describe("separate order vs project sequences (coherence)", () => {
|
||||||
it("order → standalone project → order produce three distinct numbers (from separate sequences)", async () => {
|
it("order → standalone project → order produce three distinct numbers (from separate sequences)", async () => {
|
||||||
const o1 = await createOrder({ ...baseOrder, create_project: true });
|
const o1 = await createOrder({ ...baseOrder, create_project: true });
|
||||||
|
|||||||
@@ -112,6 +112,55 @@ describe("generateProjectNumber", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("cross-sequence isolation (shared vs project)", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.number_sequences.deleteMany({
|
||||||
|
where: { type: { in: ["shared", "project"] } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await prisma.number_sequences.deleteMany({
|
||||||
|
where: { type: { in: ["shared", "project"] } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generateSharedNumber advances ONLY the shared row; generateProjectNumber ONLY the project row", async () => {
|
||||||
|
// Seed the two sequences to DISTINCT known highs so neither generated
|
||||||
|
// number collides with a real row (skip-taken retry never fires) and a
|
||||||
|
// cross-contamination bug is unambiguous.
|
||||||
|
await seedSequence("shared", 900000);
|
||||||
|
await seedSequence("project", 500000);
|
||||||
|
|
||||||
|
await generateSharedNumber();
|
||||||
|
// After a shared allocation only the shared row may have moved.
|
||||||
|
let shared = await prisma.number_sequences.findFirst({
|
||||||
|
where: { type: "shared", year: YEAR },
|
||||||
|
select: { last_number: true },
|
||||||
|
});
|
||||||
|
let project = await prisma.number_sequences.findFirst({
|
||||||
|
where: { type: "project", year: YEAR },
|
||||||
|
select: { last_number: true },
|
||||||
|
});
|
||||||
|
expect(shared?.last_number).toBe(900001);
|
||||||
|
expect(project?.last_number).toBe(500000);
|
||||||
|
|
||||||
|
await generateProjectNumber();
|
||||||
|
// After a project allocation only the project row may have moved; the
|
||||||
|
// shared row must still sit at 900001.
|
||||||
|
shared = await prisma.number_sequences.findFirst({
|
||||||
|
where: { type: "shared", year: YEAR },
|
||||||
|
select: { last_number: true },
|
||||||
|
});
|
||||||
|
project = await prisma.number_sequences.findFirst({
|
||||||
|
where: { type: "project", year: YEAR },
|
||||||
|
select: { last_number: true },
|
||||||
|
});
|
||||||
|
expect(shared?.last_number).toBe(900001);
|
||||||
|
expect(project?.last_number).toBe(500001);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("generateOfferNumber", () => {
|
describe("generateOfferNumber", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
|
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
|
||||||
|
|||||||
@@ -1166,6 +1166,9 @@ export default function CompanySettings({
|
|||||||
prefixKey: null,
|
prefixKey: null,
|
||||||
codeKey: "order_type_code" as const,
|
codeKey: "order_type_code" as const,
|
||||||
codeLabel: "Typový kód",
|
codeLabel: "Typový kód",
|
||||||
|
// Mirror the server generator's code fallback so the preview
|
||||||
|
// matches the real generated number when the field is empty.
|
||||||
|
defaultCode: "71",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Objednávky vydané",
|
label: "Objednávky vydané",
|
||||||
@@ -1174,6 +1177,7 @@ export default function CompanySettings({
|
|||||||
prefixKey: null,
|
prefixKey: null,
|
||||||
codeKey: "issued_order_type_code" as const,
|
codeKey: "issued_order_type_code" as const,
|
||||||
codeLabel: "Typový kód",
|
codeLabel: "Typový kód",
|
||||||
|
defaultCode: "72",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Projekty",
|
label: "Projekty",
|
||||||
@@ -1182,6 +1186,7 @@ export default function CompanySettings({
|
|||||||
prefixKey: null,
|
prefixKey: null,
|
||||||
codeKey: "project_type_code" as const,
|
codeKey: "project_type_code" as const,
|
||||||
codeLabel: "Typový kód",
|
codeLabel: "Typový kód",
|
||||||
|
defaultCode: "73",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Faktury",
|
label: "Faktury",
|
||||||
@@ -1190,6 +1195,7 @@ export default function CompanySettings({
|
|||||||
prefixKey: null,
|
prefixKey: null,
|
||||||
codeKey: "invoice_type_code" as const,
|
codeKey: "invoice_type_code" as const,
|
||||||
codeLabel: "Typový kód",
|
codeLabel: "Typový kód",
|
||||||
|
defaultCode: "81",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Sklad — Příjmy",
|
label: "Sklad — Příjmy",
|
||||||
@@ -1220,7 +1226,11 @@ export default function CompanySettings({
|
|||||||
const yyyy = String(new Date().getFullYear());
|
const yyyy = String(new Date().getFullYear());
|
||||||
const yy = yyyy.slice(-2);
|
const yy = yyyy.slice(-2);
|
||||||
const prefix = cfg.prefixKey ? form[cfg.prefixKey] || "NA" : "";
|
const prefix = cfg.prefixKey ? form[cfg.prefixKey] || "NA" : "";
|
||||||
const code = cfg.codeKey ? form[cfg.codeKey] || "" : "";
|
const code = cfg.codeKey
|
||||||
|
? form[cfg.codeKey] ||
|
||||||
|
(cfg as { defaultCode?: string }).defaultCode ||
|
||||||
|
""
|
||||||
|
: "";
|
||||||
const preview = pattern.replace(
|
const preview = pattern.replace(
|
||||||
/\{(\w+)\}/g,
|
/\{(\w+)\}/g,
|
||||||
(m: string, k: string) => {
|
(m: string, k: string) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
releaseSharedNumber,
|
releaseSharedNumber,
|
||||||
isOrderNumberTaken,
|
isOrderNumberTaken,
|
||||||
generateProjectNumber,
|
generateProjectNumber,
|
||||||
|
releaseProjectNumber,
|
||||||
} from "./numbering.service";
|
} from "./numbering.service";
|
||||||
import { NasFileManager } from "./nas-file-manager";
|
import { NasFileManager } from "./nas-file-manager";
|
||||||
|
|
||||||
@@ -731,6 +732,21 @@ export async function deleteOrder(id: number, deleteFiles = false) {
|
|||||||
: new Date().getFullYear();
|
: new Date().getFullYear();
|
||||||
await releaseSharedNumber(year, existing.order_number ?? undefined);
|
await releaseSharedNumber(year, existing.order_number ?? undefined);
|
||||||
|
|
||||||
|
// Release each deleted project's OWN number from the dedicated `project`
|
||||||
|
// sequence. Auto-created projects now draw a distinct number from the
|
||||||
|
// `project` pool (not the order's `shared` number), so deleting the order
|
||||||
|
// must release the project number too — otherwise the highest project number
|
||||||
|
// leaves a permanent gap. Use each project's OWN created_at year: project
|
||||||
|
// numbers embed {YY} and the sequence is (type, year)-keyed, so the order's
|
||||||
|
// year could decrement the wrong row. Best-effort, post-commit, mirroring the
|
||||||
|
// shared release above — releaseSequence row-locks and only decrements when
|
||||||
|
// the deleted number is the current highest, so this is safe/idempotent.
|
||||||
|
for (const p of linkedProjects) {
|
||||||
|
if (!p.project_number) continue;
|
||||||
|
const projYear = p.created_at ? new Date(p.created_at).getFullYear() : year;
|
||||||
|
await releaseProjectNumber(projYear, p.project_number);
|
||||||
|
}
|
||||||
|
|
||||||
return { data: { id, order_number: existing.order_number } };
|
return { data: { id, order_number: existing.order_number } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user