- 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

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

View File

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

View File

@@ -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",
},
});
}
}

View File

@@ -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;
}

View File

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

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,

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -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();
}