1.5.2
- feat: order confirmation PDF generation with VAT support - feat: order confirmation modal with custom item editing - fix: attendance negative duration clamping and switchProject timing - fix: Quill editor locked to Tahoma 14px, PDF heading sizes - fix: invoice/offer PDF font consistency (Tahoma enforcement) - fix: invoice alert cron improvements - fix: NAS financials manager edge cases - refactor: numbering service with unique sequence constraints Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,11 @@
|
||||
import prisma from "../config/database";
|
||||
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}";
|
||||
@@ -26,45 +33,6 @@ function applyPattern(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the static prefix and sequence position from a pattern.
|
||||
* Used to build SQL LIKE patterns for MAX(seq) queries.
|
||||
*/
|
||||
function buildLikePattern(
|
||||
pattern: string,
|
||||
vars: { year: number; prefix: string; code: string },
|
||||
): { likePattern: string; prefixLen: number } {
|
||||
const yyyy = String(vars.year);
|
||||
const yy = yyyy.slice(-2);
|
||||
|
||||
let staticPrefix = "";
|
||||
let foundSeq = false;
|
||||
const parts = pattern.split(/(\{[^}]+\})/);
|
||||
|
||||
for (const part of parts) {
|
||||
const m = part.match(/^\{(\w+)\}$/);
|
||||
if (!m) {
|
||||
staticPrefix += part;
|
||||
continue;
|
||||
}
|
||||
const key = m[1];
|
||||
if (/^N+$/.test(key)) {
|
||||
foundSeq = true;
|
||||
break;
|
||||
}
|
||||
if (key === "YYYY") staticPrefix += yyyy;
|
||||
else if (key === "YY") staticPrefix += yy;
|
||||
else if (key === "PREFIX") staticPrefix += vars.prefix;
|
||||
else if (key === "CODE") staticPrefix += vars.code;
|
||||
}
|
||||
|
||||
if (!foundSeq) {
|
||||
return { likePattern: staticPrefix + "%", prefixLen: staticPrefix.length };
|
||||
}
|
||||
|
||||
return { likePattern: staticPrefix + "%", prefixLen: staticPrefix.length };
|
||||
}
|
||||
|
||||
async function getSettings() {
|
||||
return prisma.company_settings.findFirst({
|
||||
select: {
|
||||
@@ -79,92 +47,237 @@ async function getSettings() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Next offer/quotation number.
|
||||
* 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 generateOfferNumber(): Promise<string> {
|
||||
async function getNextSequence(
|
||||
type: string,
|
||||
year: number,
|
||||
tx?: TxClient,
|
||||
): Promise<number> {
|
||||
const exec = async (client: TxClient) => {
|
||||
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) {
|
||||
await client.$executeRaw`
|
||||
INSERT INTO number_sequences (\`type\`, \`year\`, \`last_number\`)
|
||||
VALUES (${type}, ${year}, 1)
|
||||
`;
|
||||
return 1;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return prisma.$transaction(exec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview the next sequence number without consuming it.
|
||||
*/
|
||||
async function previewNextSequence(
|
||||
type: string,
|
||||
year: number,
|
||||
): Promise<number> {
|
||||
const existing = await prisma.$queryRaw<Array<{ last_number: number }>>`
|
||||
SELECT last_number FROM number_sequences
|
||||
WHERE \`type\` = ${type} AND \`year\` = ${year}
|
||||
`;
|
||||
|
||||
if (existing.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return existing[0].last_number + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement the sequence counter for a given type/year.
|
||||
* Called after deleting a document so the number can be reused.
|
||||
*/
|
||||
async function releaseSequence(type: string, year: number) {
|
||||
try {
|
||||
await prisma.$executeRaw`
|
||||
UPDATE number_sequences
|
||||
SET last_number = GREATEST(COALESCE(last_number, 0) - 1, 0)
|
||||
WHERE \`type\` = ${type} AND \`year\` = ${year}
|
||||
`;
|
||||
} catch (err) {
|
||||
// Non-fatal: log but don't fail the delete operation
|
||||
console.error(`releaseSequence failed for ${type}/${year}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify a shared number is not already used by an order or project. */
|
||||
async function isSharedNumberTaken(number: string): Promise<boolean> {
|
||||
const [existingOrder, existingProject] = await Promise.all([
|
||||
prisma.orders.findFirst({ where: { order_number: number } }),
|
||||
prisma.projects.findFirst({ where: { project_number: number } }),
|
||||
]);
|
||||
return !!(existingOrder || existingProject);
|
||||
}
|
||||
|
||||
/** Verify an invoice number is not already used. */
|
||||
async function isInvoiceNumberTaken(number: string): Promise<boolean> {
|
||||
const existing = await prisma.invoices.findFirst({
|
||||
where: { invoice_number: number },
|
||||
});
|
||||
return !!existing;
|
||||
}
|
||||
|
||||
/** Verify an offer/quotation number is not already used. */
|
||||
async function isOfferNumberTaken(number: string): Promise<boolean> {
|
||||
const existing = await prisma.quotations.findFirst({
|
||||
where: { quotation_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<string> {
|
||||
const settings = await getSettings();
|
||||
const pattern = settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN;
|
||||
const prefix = settings?.quotation_prefix || "NA";
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
const { likePattern, prefixLen } = buildLikePattern(pattern, {
|
||||
year,
|
||||
prefix,
|
||||
code: "",
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>`
|
||||
SELECT COALESCE(MAX(CAST(SUBSTRING(quotation_number, ${prefixLen} + 1) AS UNSIGNED)), 0) as max_seq
|
||||
FROM quotations
|
||||
WHERE quotation_number LIKE ${likePattern}
|
||||
`;
|
||||
const nextNum = Number(result[0]?.max_seq ?? 0) + 1;
|
||||
|
||||
return applyPattern(pattern, { year, prefix, code: "", seq: nextNum });
|
||||
throw new Error("Nepodařilo se vygenerovat jedinečné číslo nabídky");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared number for orders and projects.
|
||||
* Preview next offer/quotation number (does NOT consume sequence).
|
||||
*/
|
||||
export async function generateSharedNumber(): Promise<string> {
|
||||
export async function previewOfferNumber(): Promise<string> {
|
||||
const settings = await getSettings();
|
||||
const pattern = settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN;
|
||||
const prefix = settings?.quotation_prefix || "NA";
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
const seq = await previewNextSequence("offer", year);
|
||||
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<string> {
|
||||
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();
|
||||
|
||||
const { likePattern, prefixLen } = buildLikePattern(pattern, {
|
||||
year,
|
||||
prefix: "",
|
||||
code,
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>`
|
||||
SELECT COALESCE(MAX(seq), 0) as max_seq FROM (
|
||||
SELECT CAST(SUBSTRING(order_number, ${prefixLen} + 1) AS UNSIGNED) AS seq
|
||||
FROM orders WHERE order_number LIKE ${likePattern}
|
||||
UNION ALL
|
||||
SELECT CAST(SUBSTRING(project_number, ${prefixLen} + 1) AS UNSIGNED) AS seq
|
||||
FROM projects WHERE project_number LIKE ${likePattern}
|
||||
) combined
|
||||
`;
|
||||
const nextNum = Number(result[0]?.max_seq ?? 0) + 1;
|
||||
|
||||
return applyPattern(pattern, { year, prefix: "", code, seq: nextNum });
|
||||
throw new Error(
|
||||
"Nepodařilo se vygenerovat jedinečné číslo objednávky/projekt",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Next invoice number.
|
||||
* Preview shared number for orders and projects (does NOT consume sequence).
|
||||
*/
|
||||
export async function previewSharedNumber(): Promise<string> {
|
||||
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();
|
||||
|
||||
const seq = await previewNextSequence("shared", year);
|
||||
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 { likePattern, prefixLen } = buildLikePattern(pattern, {
|
||||
year,
|
||||
prefix: "",
|
||||
code,
|
||||
});
|
||||
|
||||
const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>`
|
||||
SELECT COALESCE(MAX(CAST(SUBSTRING(invoice_number, ${prefixLen} + 1) AS UNSIGNED)), 0) as max_seq
|
||||
FROM invoices
|
||||
WHERE invoice_number LIKE ${likePattern}
|
||||
`;
|
||||
const nextNum = Number(result[0]?.max_seq ?? 0) + 1;
|
||||
|
||||
const number = applyPattern(pattern, {
|
||||
year,
|
||||
prefix: "",
|
||||
code,
|
||||
seq: nextNum,
|
||||
});
|
||||
const seq = await previewNextSequence("invoice", year);
|
||||
const number = applyPattern(pattern, { year, prefix: "", code, seq });
|
||||
return { number, next_number: number };
|
||||
}
|
||||
|
||||
/** Release an offer number back to the pool (decrement sequence). */
|
||||
export async function releaseOfferNumber(year?: number) {
|
||||
await releaseSequence("offer", year || new Date().getFullYear());
|
||||
}
|
||||
|
||||
/** Release a shared number back to the pool (decrement sequence). */
|
||||
export async function releaseSharedNumber(year?: number) {
|
||||
await releaseSequence("shared", year || new Date().getFullYear());
|
||||
}
|
||||
|
||||
/** Release an invoice number back to the pool (decrement sequence). */
|
||||
export async function releaseInvoiceNumber(year?: number) {
|
||||
await releaseSequence("invoice", year || new Date().getFullYear());
|
||||
}
|
||||
|
||||
/** Preview what a pattern would produce (for settings UI) */
|
||||
export function previewPattern(
|
||||
pattern: string,
|
||||
|
||||
Reference in New Issue
Block a user