- 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:
BOHA
2026-04-23 17:23:10 +02:00
parent b197017644
commit 07cb428287
36 changed files with 2233 additions and 480 deletions

View File

@@ -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,