feat(numbering): projects get their own dedicated number sequence (decoupled from orders)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 19:24:51 +02:00
parent 9e9aacdf0b
commit ed9ea52a44
5 changed files with 148 additions and 22 deletions

View File

@@ -13,6 +13,7 @@ const DEFAULT_OFFER_PATTERN = "{YYYY}/{PREFIX}/{NNN}";
const DEFAULT_ORDER_PATTERN = "{YY}{CODE}{NNNN}";
const DEFAULT_INVOICE_PATTERN = "{YY}{CODE}{NNNN}";
const DEFAULT_ISSUED_ORDER_PATTERN = "{YY}{CODE}{NNNN}";
const DEFAULT_PROJECT_PATTERN = "{YY}{CODE}{NNNN}";
/**
* Apply a numbering pattern template.
@@ -46,6 +47,8 @@ async function getSettings() {
invoice_number_pattern: true,
issued_order_number_pattern: true,
issued_order_type_code: true,
project_number_pattern: true,
project_type_code: true,
},
});
}
@@ -156,7 +159,9 @@ async function releaseSequence(
: type === "issued_order"
? settings?.issued_order_number_pattern ||
DEFAULT_ISSUED_ORDER_PATTERN
: settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN;
: type === "project"
? settings?.project_number_pattern || DEFAULT_PROJECT_PATTERN
: settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN;
const code =
type === "shared"
? settings?.order_type_code || "71"
@@ -164,7 +169,9 @@ async function releaseSequence(
? settings?.invoice_type_code || "81"
: type === "issued_order"
? settings?.issued_order_type_code || "72"
: "";
: type === "project"
? settings?.project_type_code || "73"
: "";
const prefix = type === "offer" ? settings?.quotation_prefix || "NA" : "";
// Lock the sequence row for the duration of the decrement so a concurrent
@@ -237,6 +244,14 @@ export async function isOrderNumberTaken(number: string): Promise<boolean> {
return !!existing;
}
/** Verify a project number is not already used. */
export async function isProjectNumberTaken(number: string): Promise<boolean> {
const existing = await prisma.projects.findFirst({
where: { project_number: number },
});
return !!existing;
}
/**
* Next offer/quotation number (consumes sequence).
* Verifies uniqueness against the quotations table; retries if taken.
@@ -330,6 +345,54 @@ export async function previewSharedNumber(): Promise<string> {
return applyPattern(pattern, { year, prefix: "", code, seq });
}
/**
* Next project number (consumes the dedicated `project` sequence).
* Decoupled from the `shared` order sequence so a project (standalone OR
* auto-created from an order) gets its own number from a separate pool.
* Verifies uniqueness against the projects table; retries if taken.
* Pass `tx` when calling inside an existing Prisma transaction.
*/
export async function generateProjectNumber(tx?: TxClient): Promise<string> {
const settings = await getSettings();
const pattern = settings?.project_number_pattern || DEFAULT_PROJECT_PATTERN;
const code = settings?.project_type_code || "73";
const year = new Date().getFullYear();
for (let attempt = 0; attempt < 100; attempt++) {
const seq = await getNextSequence("project", year, tx);
const number = applyPattern(pattern, { year, prefix: "", code, seq });
if (!(await isProjectNumberTaken(number))) {
return number;
}
}
throw new Error("Nepodařilo se vygenerovat jedinečné číslo projektu");
}
/**
* Preview next project number (does NOT consume sequence).
*/
export async function previewProjectNumber(): Promise<string> {
const settings = await getSettings();
const pattern = settings?.project_number_pattern || DEFAULT_PROJECT_PATTERN;
const code = settings?.project_type_code || "73";
const year = new Date().getFullYear();
// Mirror generateProjectNumber's collision check (without consuming the
// sequence). The number_sequences counter can lag behind real data — e.g.
// when projects were imported or seeded without bumping it — so the raw next
// sequence may already be taken. Advance past any number already used by a
// project, so the preview matches what generateProjectNumber would actually
// assign instead of showing a stale, already-used number.
let seq = await previewNextSequence("project", year);
for (let attempt = 0; attempt < 1000; attempt++) {
const number = applyPattern(pattern, { year, prefix: "", code, seq });
if (!(await isProjectNumberTaken(number))) return number;
seq++;
}
return applyPattern(pattern, { year, prefix: "", code, seq });
}
/**
* Next invoice number (consumes sequence).
* Verifies uniqueness against the invoices table; retries if taken.
@@ -519,6 +582,18 @@ export async function releaseSharedNumber(
);
}
/** Release a project number back to the pool (decrement if highest). */
export async function releaseProjectNumber(
year?: number,
deletedNumber?: string,
) {
await releaseSequence(
"project",
year || new Date().getFullYear(),
deletedNumber,
);
}
/** Release an invoice number back to the pool (decrement if highest). */
export async function releaseInvoiceNumber(
year?: number,

View File

@@ -5,6 +5,7 @@ import {
previewSharedNumber,
releaseSharedNumber,
isOrderNumberTaken,
generateProjectNumber,
} from "./numbering.service";
import { NasFileManager } from "./nas-file-manager";
@@ -241,7 +242,10 @@ export async function createOrderFromQuotation(
const result = await prisma.$transaction(async (tx) => {
const orderNumber = await generateSharedNumber(tx);
const projectNumber = orderNumber;
// The order keeps the shared (order) sequence; the auto-created project
// gets its OWN number from the dedicated project sequence. Both are
// assigned inside this transaction so they're allocated atomically.
const projectNumber = await generateProjectNumber(tx);
const order = await tx.orders.create({
data: {
@@ -299,7 +303,7 @@ export async function createOrderFromQuotation(
data: {
project_number: projectNumber,
name:
quotation.project_code || quotation.quotation_number || orderNumber,
quotation.project_code || quotation.quotation_number || projectNumber,
customer_id: quotation.customer_id,
quotation_id: quotationId,
order_id: order.id,
@@ -429,13 +433,16 @@ export async function createOrder(
let projectId: number | undefined;
let createdProject: { project_number: string; name: string } | null =
null;
// Only auto-create a project for genuine offer-less orders; share the
// order's number (same shared pool) exactly like the from-quotation flow.
// Only auto-create a project for genuine offer-less orders. The project
// gets its OWN number from the dedicated project sequence (not the
// order's shared number), exactly like the from-quotation flow.
if (body.create_project !== false && body.quotation_id == null) {
const projectNumber = await generateProjectNumber(tx);
const project = await tx.projects.create({
data: {
project_number: orderNumber,
name: body.scope_title || body.customer_order_number || orderNumber,
project_number: projectNumber,
name:
body.scope_title || body.customer_order_number || projectNumber,
customer_id: order.customer_id,
order_id: order.id,
status: "aktivni",

View File

@@ -1,8 +1,8 @@
import prisma from "../config/database";
import {
generateSharedNumber,
previewSharedNumber,
releaseSharedNumber,
generateProjectNumber,
previewProjectNumber,
releaseProjectNumber,
} from "./numbering.service";
import { NasFileManager } from "./nas-file-manager";
import type { CreateProjectInput } from "../schemas/projects.schema";
@@ -177,7 +177,7 @@ export async function createProject(data: CreateProjectInput) {
}
const project = await prisma.$transaction(async (tx) => {
const project_number = await generateSharedNumber(tx);
const project_number = await generateProjectNumber(tx);
return tx.projects.create({
data: {
project_number,
@@ -210,9 +210,9 @@ export async function createProject(data: CreateProjectInput) {
return project;
}
/** Preview the next shared number for the create form (does NOT consume it). */
/** Preview the next project number for the create form (does NOT consume it). */
export async function getNextProjectNumber(): Promise<string> {
return previewSharedNumber();
return previewProjectNumber();
}
export async function deleteProject(id: number, deleteFiles: boolean = false) {
@@ -241,7 +241,7 @@ export async function deleteProject(id: number, deleteFiles: boolean = false) {
const year = existing.created_at
? new Date(existing.created_at).getFullYear()
: new Date().getFullYear();
await releaseSharedNumber(year, existing.project_number ?? undefined);
await releaseProjectNumber(year, existing.project_number ?? undefined);
return existing;
}