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:
@@ -419,17 +419,32 @@ export async function switchProject(userId: number, projectId: number | null) {
|
||||
|
||||
const now = new Date();
|
||||
|
||||
await prisma.attendance_project_logs.updateMany({
|
||||
// End active project logs, ensuring ended_at is never before started_at
|
||||
// (can happen when arrival_time was rounded up and now is still earlier)
|
||||
const activeLogs = await prisma.attendance_project_logs.findMany({
|
||||
where: { attendance_id: ongoing.id, ended_at: null },
|
||||
data: { ended_at: now },
|
||||
});
|
||||
for (const log of activeLogs) {
|
||||
const endedAt =
|
||||
log.started_at && log.started_at > now ? log.started_at : now;
|
||||
await prisma.attendance_project_logs.update({
|
||||
where: { id: log.id },
|
||||
data: { ended_at: endedAt },
|
||||
});
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
const existingLogs = await prisma.attendance_project_logs.count({
|
||||
where: { attendance_id: ongoing.id },
|
||||
});
|
||||
const isFirstProject = existingLogs === 0;
|
||||
let startedAt = isFirstProject ? ongoing.arrival_time! : now;
|
||||
if (startedAt > now) startedAt = now;
|
||||
await prisma.attendance_project_logs.create({
|
||||
data: {
|
||||
attendance_id: ongoing.id,
|
||||
project_id: projectId,
|
||||
started_at: now,
|
||||
started_at: startedAt,
|
||||
ended_at: null,
|
||||
},
|
||||
});
|
||||
@@ -630,19 +645,28 @@ export async function getProjectReport(year: number) {
|
||||
},
|
||||
include: {
|
||||
users: { select: { id: true, first_name: true, last_name: true } },
|
||||
attendance_project_logs: {
|
||||
orderBy: { started_at: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const projectIds = [
|
||||
...new Set(records.filter((r) => r.project_id).map((r) => r.project_id!)),
|
||||
];
|
||||
// Collect all project ids from both attendance.project_id and project logs
|
||||
const projectIds = new Set<number>();
|
||||
for (const rec of records) {
|
||||
if (rec.project_id) projectIds.add(rec.project_id);
|
||||
for (const log of rec.attendance_project_logs) {
|
||||
projectIds.add(log.project_id);
|
||||
}
|
||||
}
|
||||
|
||||
const projectsMap = new Map<
|
||||
number,
|
||||
{ name: string; project_number: string }
|
||||
>();
|
||||
if (projectIds.length > 0) {
|
||||
if (projectIds.size > 0) {
|
||||
const projects = await prisma.projects.findMany({
|
||||
where: { id: { in: projectIds } },
|
||||
where: { id: { in: [...projectIds] } },
|
||||
select: { id: true, name: true, project_number: true },
|
||||
});
|
||||
for (const p of projects) {
|
||||
@@ -686,32 +710,68 @@ export async function getProjectReport(year: number) {
|
||||
>();
|
||||
|
||||
for (const rec of monthRecs) {
|
||||
const hours = calcWorkedHours(
|
||||
rec.arrival_time!,
|
||||
rec.departure_time!,
|
||||
rec.break_start,
|
||||
rec.break_end,
|
||||
);
|
||||
const pid = rec.project_id;
|
||||
|
||||
if (!projectMap.has(pid)) {
|
||||
const projInfo = pid ? projectsMap.get(pid) : undefined;
|
||||
projectMap.set(pid, {
|
||||
project_number: projInfo?.project_number || undefined,
|
||||
project_name: projInfo?.name || undefined,
|
||||
userMap: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
const pg = projectMap.get(pid)!;
|
||||
const uid = rec.user_id;
|
||||
const uName = rec.users
|
||||
? `${rec.users.first_name} ${rec.users.last_name}`.trim()
|
||||
: `User #${uid}`;
|
||||
if (!pg.userMap.has(uid)) {
|
||||
pg.userMap.set(uid, { name: uName, hours: 0 });
|
||||
|
||||
if (rec.attendance_project_logs.length === 0) {
|
||||
// No detailed project logs — fall back to attendance.project_id
|
||||
const pid = rec.project_id;
|
||||
const hours = calcWorkedHours(
|
||||
rec.arrival_time!,
|
||||
rec.departure_time!,
|
||||
rec.break_start,
|
||||
rec.break_end,
|
||||
);
|
||||
|
||||
if (!projectMap.has(pid)) {
|
||||
const projInfo = pid ? projectsMap.get(pid) : undefined;
|
||||
projectMap.set(pid, {
|
||||
project_number: projInfo?.project_number || undefined,
|
||||
project_name: projInfo?.name || undefined,
|
||||
userMap: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
const pg = projectMap.get(pid)!;
|
||||
if (!pg.userMap.has(uid)) {
|
||||
pg.userMap.set(uid, { name: uName, hours: 0 });
|
||||
}
|
||||
pg.userMap.get(uid)!.hours += hours;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use detailed project logs (started_at/ended_at or hours/minutes)
|
||||
for (const log of rec.attendance_project_logs) {
|
||||
let hours = 0;
|
||||
if (log.hours != null || log.minutes != null) {
|
||||
hours = (Number(log.hours) || 0) + (Number(log.minutes) || 0) / 60;
|
||||
} else if (log.started_at && log.ended_at) {
|
||||
hours =
|
||||
(new Date(log.ended_at).getTime() -
|
||||
new Date(log.started_at).getTime()) /
|
||||
(1000 * 60 * 60);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pid = log.project_id;
|
||||
if (!projectMap.has(pid)) {
|
||||
const projInfo = projectsMap.get(pid);
|
||||
projectMap.set(pid, {
|
||||
project_number: projInfo?.project_number || undefined,
|
||||
project_name: projInfo?.name || undefined,
|
||||
userMap: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
const pg = projectMap.get(pid)!;
|
||||
if (!pg.userMap.has(uid)) {
|
||||
pg.userMap.set(uid, { name: uName, hours: 0 });
|
||||
}
|
||||
pg.userMap.get(uid)!.hours += hours;
|
||||
}
|
||||
pg.userMap.get(uid)!.hours += hours;
|
||||
}
|
||||
|
||||
const projects = Array.from(projectMap.entries()).map(([pid, pg]) => ({
|
||||
@@ -1315,10 +1375,20 @@ export async function punchAction(userId: number, data: PunchData) {
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
await prisma.attendance_project_logs.updateMany({
|
||||
// End active project logs, ensuring ended_at is never before started_at
|
||||
const activeLogs = await prisma.attendance_project_logs.findMany({
|
||||
where: { attendance_id: ongoing.id, ended_at: null },
|
||||
data: { ended_at: departureTime },
|
||||
});
|
||||
for (const log of activeLogs) {
|
||||
const endedAt =
|
||||
log.started_at && log.started_at > departureTime
|
||||
? log.started_at
|
||||
: departureTime;
|
||||
await prisma.attendance_project_logs.update({
|
||||
where: { id: log.id },
|
||||
data: { ended_at: endedAt },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: ongoing.id,
|
||||
|
||||
@@ -2,6 +2,27 @@ import { FastifyRequest } from "fastify";
|
||||
import prisma from "../config/database";
|
||||
import { AuditAction, EntityType, AuthData } from "../types";
|
||||
|
||||
/**
|
||||
* Safe JSON.stringify replacer that handles Prisma Decimal and BigInt
|
||||
* by converting them to strings. Prevents JSON.stringify from throwing
|
||||
* on values that include these types.
|
||||
*/
|
||||
function safeJsonReplacer(_key: string, value: unknown): unknown {
|
||||
if (typeof value === "bigint") return String(value);
|
||||
if (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
"toString" in value &&
|
||||
typeof (value as any).toString === "function" &&
|
||||
"toNumber" in value &&
|
||||
typeof (value as any).toNumber === "function"
|
||||
) {
|
||||
// Prisma Decimal
|
||||
return (value as any).toString();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function logAudit(params: {
|
||||
request: FastifyRequest;
|
||||
authData?: AuthData | null;
|
||||
@@ -22,8 +43,12 @@ export async function logAudit(params: {
|
||||
entity_type: params.entityType ?? null,
|
||||
entity_id: params.entityId ?? null,
|
||||
description: params.description ?? null,
|
||||
old_values: params.oldValues ? JSON.stringify(params.oldValues) : null,
|
||||
new_values: params.newValues ? JSON.stringify(params.newValues) : null,
|
||||
old_values: params.oldValues
|
||||
? JSON.stringify(params.oldValues, safeJsonReplacer)
|
||||
: null,
|
||||
new_values: params.newValues
|
||||
? JSON.stringify(params.newValues, safeJsonReplacer)
|
||||
: null,
|
||||
user_agent: params.request.headers["user-agent"] ?? null,
|
||||
session_id: null,
|
||||
},
|
||||
|
||||
@@ -99,14 +99,6 @@ export async function checkInvoiceAlerts(): Promise<void> {
|
||||
due_date: localDateCzStr(new Date(inv.due_date)),
|
||||
days_label: daysLabel,
|
||||
});
|
||||
|
||||
await prisma.invoice_alert_log.create({
|
||||
data: {
|
||||
invoice_type: "created",
|
||||
invoice_id: inv.id,
|
||||
alert_type: alertType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Received invoices (we owe supplier) ---
|
||||
@@ -155,14 +147,6 @@ export async function checkInvoiceAlerts(): Promise<void> {
|
||||
due_date: localDateCzStr(new Date(inv.due_date)),
|
||||
days_label: daysLabel,
|
||||
});
|
||||
|
||||
await prisma.invoice_alert_log.create({
|
||||
data: {
|
||||
invoice_type: "received",
|
||||
invoice_id: inv.id,
|
||||
alert_type: alertType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (alerts.length === 0) return;
|
||||
@@ -221,9 +205,26 @@ export async function checkInvoiceAlerts(): Promise<void> {
|
||||
const sent = await sendMail(alertEmail, subject, html);
|
||||
if (!sent) {
|
||||
console.error(`InvoiceAlerts: Failed to send alert to ${alertEmail}`);
|
||||
} else {
|
||||
console.log(
|
||||
`InvoiceAlerts: Sent ${alerts.length} alert(s) to ${alertEmail}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`InvoiceAlerts: Sent ${alerts.length} alert(s) to ${alertEmail}`);
|
||||
|
||||
// Mark alerts as sent only after successful delivery
|
||||
for (const a of alerts) {
|
||||
await prisma.invoice_alert_log.create({
|
||||
data: {
|
||||
invoice_type: a.type,
|
||||
invoice_id: a.id,
|
||||
alert_type:
|
||||
a.type === "created"
|
||||
? a.days_label.includes("dnes")
|
||||
? "due"
|
||||
: "3days"
|
||||
: a.days_label.includes("dnes")
|
||||
? "due"
|
||||
: "3days",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import prisma from "../config/database";
|
||||
import { toCzk } from "./exchange-rates";
|
||||
import {
|
||||
generateInvoiceNumber,
|
||||
releaseInvoiceNumber,
|
||||
} from "./numbering.service";
|
||||
|
||||
// Status transition rules matching PHP
|
||||
const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||
@@ -147,7 +151,10 @@ export async function listInvoices(params: ListInvoicesParams) {
|
||||
return { data: enriched, total, page, limit };
|
||||
}
|
||||
|
||||
export { generateInvoiceNumber as getNextInvoiceNumberFormatted } from "./numbering.service";
|
||||
export {
|
||||
generateInvoiceNumber as getNextInvoiceNumberFormatted,
|
||||
previewInvoiceNumber as getNextInvoiceNumberPreview,
|
||||
} from "./numbering.service";
|
||||
|
||||
export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
||||
const now = new Date();
|
||||
@@ -293,9 +300,14 @@ export async function getInvoice(id: number) {
|
||||
}
|
||||
|
||||
export async function createInvoice(body: Record<string, any>) {
|
||||
const invoiceNumber =
|
||||
body.invoice_number !== undefined && body.invoice_number !== null
|
||||
? String(body.invoice_number)
|
||||
: (await generateInvoiceNumber()).number;
|
||||
|
||||
const invoice = await prisma.invoices.create({
|
||||
data: {
|
||||
invoice_number: body.invoice_number ? String(body.invoice_number) : null,
|
||||
invoice_number: invoiceNumber,
|
||||
order_id: body.order_id ? Number(body.order_id) : null,
|
||||
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
||||
status: body.status ? String(body.status) : "issued",
|
||||
@@ -410,7 +422,7 @@ export async function updateInvoice(id: number, body: Record<string, any>) {
|
||||
}
|
||||
}
|
||||
|
||||
if (body.paid_date !== undefined)
|
||||
if (body.paid_date !== undefined && currentStatus !== "paid")
|
||||
data.paid_date = body.paid_date ? new Date(String(body.paid_date)) : null;
|
||||
|
||||
await prisma.invoices.update({ where: { id }, data });
|
||||
@@ -441,5 +453,11 @@ export async function deleteInvoice(id: number) {
|
||||
if (!existing) return null;
|
||||
|
||||
await prisma.invoices.delete({ where: { id } });
|
||||
|
||||
const year = existing.created_at
|
||||
? new Date(existing.created_at).getFullYear()
|
||||
: new Date().getFullYear();
|
||||
await releaseInvoiceNumber(year);
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
@@ -545,13 +545,14 @@ export class NasFileManager {
|
||||
}
|
||||
|
||||
private buildFolderName(projectNumber: string, projectName: string): string {
|
||||
let safeNum = projectNumber.replace(/[^\p{L}\p{N}_\-.]/gu, "");
|
||||
safeNum = safeNum.replace(/^\.+|\.+$/g, "").trim();
|
||||
let safe = projectName.replace(/[^\p{L}\p{N}_\-. ]/gu, "");
|
||||
safe = safe.trim().replace(/ /g, "_");
|
||||
safe = safe.replace(/_+/g, "_");
|
||||
safe = safe.trim().replace(/ /g, "_").replace(/_+/g, "_");
|
||||
if ([...safe].length > 200) {
|
||||
safe = [...safe].slice(0, 200).join("");
|
||||
}
|
||||
return projectNumber + "_" + safe;
|
||||
return safeNum + "_" + safe;
|
||||
}
|
||||
|
||||
private resolveProjectPath(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import prisma from "../config/database";
|
||||
import { generateOfferNumber } from "./numbering.service";
|
||||
import {
|
||||
generateOfferNumber,
|
||||
previewOfferNumber,
|
||||
releaseOfferNumber,
|
||||
} from "./numbering.service";
|
||||
|
||||
interface QuotationItemInput {
|
||||
description?: string;
|
||||
@@ -18,7 +22,7 @@ interface ScopeSectionInput {
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
export { generateOfferNumber as getNextOfferNumber } from "./numbering.service";
|
||||
export { previewOfferNumber as getNextOfferNumber } from "./numbering.service";
|
||||
|
||||
const ALLOWED_SORT_FIELDS = [
|
||||
"id",
|
||||
@@ -133,11 +137,14 @@ export async function getOffer(id: number) {
|
||||
}
|
||||
|
||||
export async function createOffer(body: Record<string, any>) {
|
||||
const quotationNumber =
|
||||
body.quotation_number !== undefined && body.quotation_number !== null
|
||||
? String(body.quotation_number)
|
||||
: await generateOfferNumber();
|
||||
|
||||
const quotation = await prisma.quotations.create({
|
||||
data: {
|
||||
quotation_number: body.quotation_number
|
||||
? String(body.quotation_number)
|
||||
: null,
|
||||
quotation_number: quotationNumber,
|
||||
project_code: body.project_code ? String(body.project_code) : null,
|
||||
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
||||
valid_until: body.valid_until ? new Date(String(body.valid_until)) : null,
|
||||
@@ -190,13 +197,16 @@ export async function updateOffer(id: number, body: Record<string, any>) {
|
||||
if (existing.status === "invalidated")
|
||||
return { error: "invalidated" as const };
|
||||
|
||||
if (
|
||||
body.quotation_number !== undefined &&
|
||||
String(body.quotation_number) !== existing.quotation_number
|
||||
) {
|
||||
return { error: "Číslo nabídky nelze změnit", status: 400 } as const;
|
||||
}
|
||||
|
||||
await prisma.quotations.update({
|
||||
where: { id },
|
||||
data: {
|
||||
quotation_number:
|
||||
body.quotation_number !== undefined
|
||||
? String(body.quotation_number)
|
||||
: undefined,
|
||||
customer_id:
|
||||
body.customer_id !== undefined ? Number(body.customer_id) : undefined,
|
||||
valid_until:
|
||||
@@ -281,6 +291,12 @@ export async function deleteOffer(id: number) {
|
||||
if (!existing) return null;
|
||||
|
||||
await prisma.quotations.delete({ where: { id } });
|
||||
|
||||
const year = existing.created_at
|
||||
? new Date(existing.created_at).getFullYear()
|
||||
: new Date().getFullYear();
|
||||
await releaseOfferNumber(year);
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import prisma from "../config/database";
|
||||
import { generateSharedNumber } from "./numbering.service";
|
||||
import {
|
||||
generateSharedNumber,
|
||||
previewSharedNumber,
|
||||
releaseSharedNumber,
|
||||
} from "./numbering.service";
|
||||
|
||||
interface OrderItemInput {
|
||||
description?: string | null;
|
||||
@@ -180,10 +184,10 @@ export async function createOrderFromQuotation(
|
||||
status: 400,
|
||||
} as const;
|
||||
|
||||
const orderNumber = await generateSharedNumber();
|
||||
const projectNumber = await generateSharedNumber();
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const orderNumber = await generateSharedNumber(tx);
|
||||
const projectNumber = orderNumber;
|
||||
|
||||
const order = await tx.orders.create({
|
||||
data: {
|
||||
order_number: orderNumber,
|
||||
@@ -249,14 +253,14 @@ export async function createOrderFromQuotation(
|
||||
},
|
||||
});
|
||||
|
||||
return { order, project };
|
||||
return { order, project, orderNumber };
|
||||
});
|
||||
|
||||
return {
|
||||
data: {
|
||||
order_id: result.order.id,
|
||||
id: result.order.id,
|
||||
order_number: orderNumber,
|
||||
order_number: result.orderNumber,
|
||||
quotationId,
|
||||
},
|
||||
};
|
||||
@@ -281,9 +285,14 @@ interface CreateOrderData {
|
||||
}
|
||||
|
||||
export async function createOrder(body: CreateOrderData) {
|
||||
const orderNumber =
|
||||
body.order_number !== undefined && body.order_number !== null
|
||||
? String(body.order_number)
|
||||
: await generateSharedNumber();
|
||||
|
||||
const order = await prisma.orders.create({
|
||||
data: {
|
||||
order_number: body.order_number ?? null,
|
||||
order_number: orderNumber,
|
||||
customer_order_number: body.customer_order_number ?? null,
|
||||
quotation_id: body.quotation_id ?? null,
|
||||
customer_id: body.customer_id ?? null,
|
||||
@@ -343,6 +352,16 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
|
||||
|
||||
const currentStatus = existing.status as string;
|
||||
|
||||
if (
|
||||
body.order_number !== undefined &&
|
||||
String(body.order_number) !== existing.order_number
|
||||
) {
|
||||
return {
|
||||
error: "Číslo objednávky nelze změnit",
|
||||
status: 400,
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (body.status !== undefined && String(body.status) !== currentStatus) {
|
||||
const newStatus = String(body.status);
|
||||
const allowed = VALID_TRANSITIONS[currentStatus] || [];
|
||||
@@ -356,7 +375,6 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
|
||||
|
||||
const data: Record<string, unknown> = { modified_at: new Date() };
|
||||
const strFields = [
|
||||
"order_number",
|
||||
"customer_order_number",
|
||||
"status",
|
||||
"currency",
|
||||
@@ -377,17 +395,6 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
|
||||
|
||||
await prisma.orders.update({ where: { id }, data });
|
||||
|
||||
// Sync project_number when order_number changes (matching PHP)
|
||||
if (
|
||||
body.order_number !== undefined &&
|
||||
String(body.order_number) !== existing.order_number
|
||||
) {
|
||||
await prisma.projects.updateMany({
|
||||
where: { order_id: id },
|
||||
data: { project_number: String(body.order_number) },
|
||||
});
|
||||
}
|
||||
|
||||
// Sync project status when order status changes (matching PHP)
|
||||
if (body.status !== undefined && String(body.status) !== currentStatus) {
|
||||
const statusMap: Record<string, string> = {
|
||||
@@ -405,6 +412,12 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
|
||||
}
|
||||
|
||||
if (Array.isArray(body.items) || Array.isArray(body.sections)) {
|
||||
if (currentStatus !== "prijata" && currentStatus !== "v_realizaci") {
|
||||
return {
|
||||
error: "Nelze upravit položky dokončené/stornované objednávky",
|
||||
status: 400,
|
||||
} as const;
|
||||
}
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (Array.isArray(body.items)) {
|
||||
await tx.order_items.deleteMany({ where: { order_id: id } });
|
||||
@@ -453,7 +466,7 @@ export async function deleteOrder(id: number) {
|
||||
// Delete linked project and its notes (matching PHP)
|
||||
const linkedProjects = await prisma.projects.findMany({
|
||||
where: { order_id: id },
|
||||
select: { id: true },
|
||||
select: { id: true, created_at: true },
|
||||
});
|
||||
if (linkedProjects.length > 0) {
|
||||
const projectIds = linkedProjects.map((p) => p.id);
|
||||
@@ -464,9 +477,23 @@ export async function deleteOrder(id: number) {
|
||||
}
|
||||
|
||||
await prisma.orders.delete({ where: { id } });
|
||||
|
||||
const year = existing.created_at
|
||||
? new Date(existing.created_at).getFullYear()
|
||||
: new Date().getFullYear();
|
||||
await releaseSharedNumber(year);
|
||||
|
||||
// Release the linked project's shared number(s) too
|
||||
for (const p of linkedProjects) {
|
||||
const pYear = p.created_at
|
||||
? new Date(p.created_at).getFullYear()
|
||||
: new Date().getFullYear();
|
||||
await releaseSharedNumber(pYear);
|
||||
}
|
||||
|
||||
return { data: { id, order_number: existing.order_number } };
|
||||
}
|
||||
|
||||
export async function getNextOrderNumber() {
|
||||
return generateSharedNumber();
|
||||
return previewSharedNumber();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import prisma from "../config/database";
|
||||
import { generateSharedNumber } from "./numbering.service";
|
||||
import {
|
||||
generateSharedNumber,
|
||||
previewSharedNumber,
|
||||
releaseSharedNumber,
|
||||
} from "./numbering.service";
|
||||
import { NasFileManager } from "./nas-file-manager";
|
||||
|
||||
const nasFileManager = new NasFileManager();
|
||||
@@ -93,9 +97,14 @@ export async function getProject(id: number) {
|
||||
}
|
||||
|
||||
export async function createProject(body: Record<string, any>) {
|
||||
const projectNumber =
|
||||
body.project_number !== undefined && body.project_number !== null
|
||||
? String(body.project_number)
|
||||
: await generateSharedNumber();
|
||||
|
||||
const project = await prisma.projects.create({
|
||||
data: {
|
||||
project_number: body.project_number ? String(body.project_number) : null,
|
||||
project_number: projectNumber,
|
||||
name: body.name ? String(body.name) : null,
|
||||
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
||||
responsible_user_id: body.responsible_user_id
|
||||
@@ -124,8 +133,15 @@ export async function updateProject(id: number, body: Record<string, any>) {
|
||||
const existing = await prisma.projects.findUnique({ where: { id } });
|
||||
if (!existing) return null;
|
||||
|
||||
if (
|
||||
body.project_number !== undefined &&
|
||||
String(body.project_number) !== existing.project_number
|
||||
) {
|
||||
return { error: "Číslo projektu nelze změnit", status: 400 };
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = { modified_at: new Date() };
|
||||
const strFields = ["project_number", "name", "status", "notes"];
|
||||
const strFields = ["name", "status", "notes"];
|
||||
for (const f of strFields)
|
||||
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
|
||||
if (body.customer_id !== undefined)
|
||||
@@ -148,13 +164,14 @@ export async function updateProject(id: number, body: Record<string, any>) {
|
||||
await prisma.projects.update({ where: { id }, data });
|
||||
|
||||
if (
|
||||
existing.name !== data.name &&
|
||||
body.name !== undefined &&
|
||||
existing.name !== body.name &&
|
||||
existing.project_number &&
|
||||
nasFileManager.isConfigured()
|
||||
) {
|
||||
nasFileManager.renameProjectFolder(
|
||||
existing.project_number,
|
||||
String(data.name || ""),
|
||||
String(body.name || ""),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -171,6 +188,12 @@ export async function deleteProject(id: number, deleteFiles: boolean = false) {
|
||||
}
|
||||
|
||||
await prisma.projects.delete({ where: { id } });
|
||||
|
||||
const year = existing.created_at
|
||||
? new Date(existing.created_at).getFullYear()
|
||||
: new Date().getFullYear();
|
||||
await releaseSharedNumber(year);
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
@@ -205,5 +228,5 @@ export async function deleteProjectNote(projectId: number, noteId: number) {
|
||||
}
|
||||
|
||||
export async function getNextProjectNumber() {
|
||||
return generateSharedNumber();
|
||||
return previewSharedNumber();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user