import prisma from "../config/database"; import { Prisma } from "@prisma/client"; import type { PrismaClient } from "@prisma/client"; // Prisma transaction client (omit methods not available inside $transaction) type TxClient = Omit< PrismaClient, "$connect" | "$disconnect" | "$on" | "$transaction" | "$extends" >; // Default patterns (backward compatible with existing numbers) 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. * Placeholders: {YYYY}, {YY}, {PREFIX}, {CODE}, {N+} (padding = count of N's) */ export function applyPattern( pattern: string, vars: { year: number; prefix: string; code: string; seq: number }, ): string { const yyyy = String(vars.year); const yy = yyyy.slice(-2); return pattern.replace(/\{(\w+)\}/g, (match, key: string) => { if (key === "YYYY") return yyyy; if (key === "YY") return yy; if (key === "PREFIX") return vars.prefix; if (key === "CODE") return vars.code; if (/^N+$/.test(key)) return String(vars.seq).padStart(key.length, "0"); return match; }); } async function getSettings() { return prisma.company_settings.findFirst({ select: { quotation_prefix: true, order_type_code: true, invoice_type_code: true, offer_number_pattern: true, order_number_pattern: true, invoice_number_pattern: true, issued_order_number_pattern: true, issued_order_type_code: true, project_number_pattern: true, project_type_code: true, }, }); } /** * Atomically get the next sequence number for a given type and year. * Uses SELECT ... FOR UPDATE inside a transaction to prevent races. * If `tx` is provided, the increment happens inside the caller's transaction * (no nested transaction is created). */ export async function getNextSequence( type: string, year: number, tx?: TxClient, ): Promise { // Read-modify-write under a row lock. The very first allocation of a // (type, year) pair has no row to lock yet, so two concurrent callers can // both see zero rows and both attempt the INSERT — the @@unique([type,year]) // constraint then raises P2002 on the loser. `allowInsertRetry` lets us catch // that one case and fall back to the locked read-then-update path, so the // first-of-year race returns the next sequence instead of a 500. const exec = async ( client: TxClient, allowInsertRetry: boolean, ): Promise => { const existing = await client.$queryRaw< Array<{ id: number; last_number: number }> >` SELECT id, last_number FROM number_sequences WHERE \`type\` = ${type} AND \`year\` = ${year} FOR UPDATE `; if (existing.length === 0) { try { await client.$executeRaw` INSERT INTO number_sequences (\`type\`, \`year\`, \`last_number\`) VALUES (${type}, ${year}, 1) `; return 1; } catch (err) { // Concurrent first-of-year insert won the race. Retry the locked // read-then-update exactly once; the row now exists so this resolves. if ( allowInsertRetry && err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002" ) { return exec(client, false); } throw err; } } const next = existing[0].last_number + 1; await client.$executeRaw` UPDATE number_sequences SET \`last_number\` = ${next} WHERE id = ${existing[0].id} `; return next; }; if (tx) { return exec(tx, true); } return prisma.$transaction((client) => exec(client, true)); } /** * Preview the next sequence number without consuming it. */ async function previewNextSequence( type: string, year: number, ): Promise { const existing = await prisma.$queryRaw>` SELECT last_number FROM number_sequences WHERE \`type\` = ${type} AND \`year\` = ${year} `; if (existing.length === 0) { return 1; } return existing[0].last_number + 1; } /** * Release a sequence number back to the pool. * Only decrements if the deleted entity held the current highest number, * preventing gaps while avoiding duplicate re-allocation. */ async function releaseSequence( type: string, year: number, deletedNumber?: string, ) { if (!deletedNumber) return; // Settings are read-only here and don't need to be inside the lock window. const settings = await getSettings(); const pattern = type === "shared" ? settings?.order_number_pattern || DEFAULT_ORDER_PATTERN : type === "invoice" ? settings?.invoice_number_pattern || DEFAULT_INVOICE_PATTERN : type === "issued_order" ? settings?.issued_order_number_pattern || DEFAULT_ISSUED_ORDER_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" : type === "invoice" ? 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 // getNextSequence (which also takes FOR UPDATE) can't read-modify-write the // same row in between — that interleaving could otherwise lose a decrement or // decrement an in-use number (gap/dup). await prisma.$transaction(async (tx) => { const existing = await tx.$queryRaw>` SELECT last_number FROM number_sequences WHERE \`type\` = ${type} AND \`year\` = ${year} FOR UPDATE `; if (existing.length === 0) return; const highestNumber = applyPattern(pattern, { year, prefix, code, seq: existing[0].last_number, }); if (deletedNumber === highestNumber && existing[0].last_number > 0) { await tx.$executeRaw` UPDATE number_sequences SET \`last_number\` = ${existing[0].last_number - 1} WHERE \`type\` = ${type} AND \`year\` = ${year} `; } }); } /** Verify a shared number is not already used by an order or project. */ export async function isSharedNumberTaken(number: string): Promise { const [existingOrder, existingProject] = await Promise.all([ // select id only — the orders row carries the PO attachment blob, which an // existence check must never pull. prisma.orders.findFirst({ where: { order_number: number }, select: { id: true }, }), prisma.projects.findFirst({ where: { project_number: number }, select: { id: true }, }), ]); return !!(existingOrder || existingProject); } /** Verify an invoice number is not already used. */ async function isInvoiceNumberTaken(number: string): Promise { const existing = await prisma.invoices.findFirst({ where: { invoice_number: number }, }); return !!existing; } /** Verify an issued-order (PO) number is not already used. */ async function isIssuedOrderNumberTaken(number: string): Promise { const existing = await prisma.issued_orders.findFirst({ where: { po_number: number }, }); return !!existing; } /** Verify an offer/quotation number is not already used. */ export async function isOfferNumberTaken(number: string): Promise { const existing = await prisma.quotations.findFirst({ where: { quotation_number: number }, }); return !!existing; } /** Verify an order number is not already used. */ export async function isOrderNumberTaken(number: string): Promise { // select id only — the orders row carries the PO attachment blob, which an // existence check must never pull. const existing = await prisma.orders.findFirst({ where: { order_number: number }, select: { id: true }, }); return !!existing; } /** Verify a project number is not already used. */ export async function isProjectNumberTaken(number: string): Promise { 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. * Pass `tx` when calling inside an existing Prisma transaction. */ export async function generateOfferNumber(tx?: TxClient): Promise { const settings = await getSettings(); const pattern = settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN; const prefix = settings?.quotation_prefix || "NA"; const year = new Date().getFullYear(); for (let attempt = 0; attempt < 100; attempt++) { const seq = await getNextSequence("offer", year, tx); const number = applyPattern(pattern, { year, prefix, code: "", seq }); if (!(await isOfferNumberTaken(number))) { return number; } } throw new Error("Nepodařilo se vygenerovat jedinečné číslo nabídky"); } /** * Preview next offer/quotation number (does NOT consume sequence). */ export async function previewOfferNumber(): Promise { const settings = await getSettings(); const pattern = settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN; const prefix = settings?.quotation_prefix || "NA"; const year = new Date().getFullYear(); // 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 }); } /** * Shared number for orders and projects (consumes sequence). * Verifies uniqueness against both orders and projects tables; retries if taken. * Pass `tx` when calling inside an existing Prisma transaction. */ export async function generateSharedNumber(tx?: TxClient): Promise { const settings = await getSettings(); const pattern = settings?.order_number_pattern || DEFAULT_ORDER_PATTERN; const code = settings?.order_type_code || "71"; const year = new Date().getFullYear(); for (let attempt = 0; attempt < 100; attempt++) { const seq = await getNextSequence("shared", year, tx); const number = applyPattern(pattern, { year, prefix: "", code, seq }); if (!(await isSharedNumberTaken(number))) { return number; } } throw new Error( "Nepodařilo se vygenerovat jedinečné číslo objednávky/projekt", ); } /** * Preview shared number for orders and projects (does NOT consume sequence). */ export async function previewSharedNumber(): Promise { const settings = await getSettings(); const pattern = settings?.order_number_pattern || DEFAULT_ORDER_PATTERN; const code = settings?.order_type_code || "71"; const year = new Date().getFullYear(); // Mirror generateSharedNumber's collision check (without consuming the // sequence). The number_sequences counter can lag behind real data — e.g. // when orders/projects were imported or seeded without bumping it — so the // raw next sequence may already be taken. Advance past any number already // used by an order or project, so the preview matches what generateSharedNumber // would actually assign instead of showing a stale, already-used number. let seq = await previewNextSequence("shared", year); for (let attempt = 0; attempt < 1000; attempt++) { const number = applyPattern(pattern, { year, prefix: "", code, seq }); if (!(await isSharedNumberTaken(number))) return number; seq++; } 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 { 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 { 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. * Pass `tx` when calling inside an existing Prisma transaction. */ export async function generateInvoiceNumber( _year?: number, tx?: TxClient, ): Promise<{ number: string; next_number: string }> { const settings = await getSettings(); const pattern = settings?.invoice_number_pattern || DEFAULT_INVOICE_PATTERN; const code = settings?.invoice_type_code || "81"; const year = _year || new Date().getFullYear(); for (let attempt = 0; attempt < 100; attempt++) { const seq = await getNextSequence("invoice", year, tx); const number = applyPattern(pattern, { year, prefix: "", code, seq }); if (!(await isInvoiceNumberTaken(number))) { return { number, next_number: number }; } } throw new Error("Nepodařilo se vygenerovat jedinečné číslo faktury"); } /** * Preview next invoice number (does NOT consume sequence). */ export async function previewInvoiceNumber( _year?: number, ): Promise<{ number: string; next_number: string }> { const settings = await getSettings(); const pattern = settings?.invoice_number_pattern || DEFAULT_INVOICE_PATTERN; const code = settings?.invoice_type_code || "81"; const year = _year || new Date().getFullYear(); const seq = await previewNextSequence("invoice", year); const number = applyPattern(pattern, { year, prefix: "", code, seq }); 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 }; } /** * Deferred-assign helpers — used when a draft (number-less) document is * finalized. Each runs inside a transaction (the caller's `tx` if provided, * otherwise a fresh `prisma.$transaction`), reads the row's current number, and: * - if it is already set (non-null/non-empty), returns it unchanged * (idempotent — finalizing twice never re-numbers / double-consumes); else * - calls the existing per-type generator (which consumes the sequence under * the same row lock) and writes the number onto the row. * The sequence is consumed exactly once, atomically with the row write. */ export async function assignOfferNumber( id: number, tx?: TxClient, ): Promise { const run = async (client: TxClient): Promise => { const row = await client.quotations.findUnique({ where: { id }, select: { quotation_number: true }, }); if (row?.quotation_number) return row.quotation_number; const number = await generateOfferNumber(client); await client.quotations.update({ where: { id }, data: { quotation_number: number }, }); return number; }; return tx ? run(tx) : prisma.$transaction(run); } export async function assignInvoiceNumber( id: number, tx?: TxClient, ): Promise { const run = async (client: TxClient): Promise => { const row = await client.invoices.findUnique({ where: { id }, select: { invoice_number: true }, }); if (row?.invoice_number) return row.invoice_number; const { number } = await generateInvoiceNumber(undefined, client); await client.invoices.update({ where: { id }, data: { invoice_number: number }, }); return number; }; return tx ? run(tx) : prisma.$transaction(run); } export async function assignIssuedOrderNumber( id: number, tx?: TxClient, ): Promise { const run = async (client: TxClient): Promise => { const row = await client.issued_orders.findUnique({ where: { id }, select: { po_number: true }, }); if (row?.po_number) return row.po_number; const { number } = await generateIssuedOrderNumber(undefined, client); await client.issued_orders.update({ where: { id }, data: { po_number: number }, }); return number; }; return tx ? run(tx) : prisma.$transaction(run); } /** 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). */ export async function releaseOfferNumber( year?: number, deletedNumber?: string, ) { await releaseSequence( "offer", year || new Date().getFullYear(), deletedNumber, ); } /** Release a shared number back to the pool (decrement if highest). */ export async function releaseSharedNumber( year?: number, deletedNumber?: string, ) { await releaseSequence( "shared", year || new Date().getFullYear(), deletedNumber, ); } /** 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, deletedNumber?: string, ) { await releaseSequence( "invoice", year || new Date().getFullYear(), deletedNumber, ); } /** Preview what a pattern would produce (for settings UI) */ export function previewPattern( pattern: string, prefix: string, code: string, ): string { return applyPattern(pattern, { year: new Date().getFullYear(), prefix, code, seq: 1, }); }