Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace the raw number|string transform idiom across every schema (the root-cause NaN bug class); emailOrEmpty + lenient isoDateString/timeString. Security: roles privilege-escalation closed; refresh-token family revocation on reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices gross VAT on all paths; orders-pdf custom-items authz. Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse confirm/cancel, attendance lockUserRow); uniqueness checks moved into create transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO). Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted fields; dashboard invalidation gaps; stale-closure confirm bugs. Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier; tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes), isolated on app_test via .env.test with a hard-throw setup guard. Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
449 lines
14 KiB
TypeScript
449 lines
14 KiB
TypeScript
import prisma from "../config/database";
|
|
import {
|
|
generateOfferNumber,
|
|
previewOfferNumber,
|
|
releaseOfferNumber,
|
|
isOfferNumberTaken,
|
|
} from "./numbering.service";
|
|
import { nasOffersManager } from "./nas-offers-manager";
|
|
|
|
interface QuotationItemInput {
|
|
description?: string;
|
|
item_description?: string;
|
|
quantity?: number;
|
|
unit?: string;
|
|
unit_price?: number;
|
|
is_included_in_total?: boolean;
|
|
position?: number;
|
|
}
|
|
interface ScopeSectionInput {
|
|
title?: string;
|
|
title_cz?: string;
|
|
content?: string;
|
|
position?: number;
|
|
}
|
|
|
|
// Re-export for convenience
|
|
export { previewOfferNumber as getNextOfferNumber } from "./numbering.service";
|
|
|
|
const ALLOWED_SORT_FIELDS = [
|
|
"id",
|
|
"quotation_number",
|
|
"project_code",
|
|
"created_at",
|
|
"valid_until",
|
|
"currency",
|
|
"status",
|
|
];
|
|
|
|
interface ListOffersParams {
|
|
page: number;
|
|
limit: number;
|
|
skip: number;
|
|
sort: string;
|
|
order: "asc" | "desc";
|
|
search: string;
|
|
status?: string;
|
|
customer_id?: number;
|
|
}
|
|
|
|
function enrichQuotation(q: any) {
|
|
const subtotal = q.quotation_items
|
|
.filter((i: any) => i.is_included_in_total !== false)
|
|
.reduce(
|
|
(s: number, i: any) =>
|
|
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
|
0,
|
|
);
|
|
const vatAmount = q.apply_vat
|
|
? subtotal *
|
|
((q.vat_rate != null && q.vat_rate !== "" ? Number(q.vat_rate) : 21) /
|
|
100)
|
|
: 0;
|
|
const { quotation_items, scope_sections, ...rest } = q;
|
|
return {
|
|
...rest,
|
|
items: quotation_items,
|
|
sections: scope_sections,
|
|
customer_name: q.customers?.name || null,
|
|
subtotal: Math.round(subtotal * 100) / 100,
|
|
vat_amount: Math.round(vatAmount * 100) / 100,
|
|
total: Math.round((subtotal + vatAmount) * 100) / 100,
|
|
};
|
|
}
|
|
|
|
export async function listOffers(params: ListOffersParams) {
|
|
const { page, limit, skip, sort, order, search, status, customer_id } =
|
|
params;
|
|
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
|
|
|
|
const where: Record<string, unknown> = {};
|
|
if (status) where.status = status;
|
|
if (customer_id) where.customer_id = customer_id;
|
|
if (search) {
|
|
where.OR = [
|
|
{ quotation_number: { contains: search } },
|
|
{ project_code: { contains: search } },
|
|
{ customers: { name: { contains: search } } },
|
|
];
|
|
}
|
|
|
|
const [quotations, total] = await Promise.all([
|
|
prisma.quotations.findMany({
|
|
where,
|
|
skip,
|
|
take: limit,
|
|
orderBy: { [sortField]: order },
|
|
include: {
|
|
customers: { select: { id: true, name: true } },
|
|
quotation_items: { orderBy: { position: "asc" } },
|
|
scope_sections: { orderBy: { position: "asc" } },
|
|
},
|
|
}),
|
|
prisma.quotations.count({ where }),
|
|
]);
|
|
|
|
// Batch-fetch linked order statuses
|
|
const orderIds = quotations
|
|
.map((q) => q.order_id)
|
|
.filter((id): id is number => id != null);
|
|
const orderStatusMap = new Map<number, string>();
|
|
if (orderIds.length > 0) {
|
|
const orders = await prisma.orders.findMany({
|
|
where: { id: { in: orderIds } },
|
|
select: { id: true, status: true },
|
|
});
|
|
for (const o of orders) {
|
|
orderStatusMap.set(o.id, o.status || "");
|
|
}
|
|
}
|
|
|
|
const enriched = quotations.map((q) => ({
|
|
...enrichQuotation(q),
|
|
order_status:
|
|
q.order_id != null ? orderStatusMap.get(q.order_id) || null : null,
|
|
}));
|
|
|
|
return { data: enriched, total, page, limit };
|
|
}
|
|
|
|
export async function getOffer(id: number) {
|
|
const quotation = await prisma.quotations.findUnique({
|
|
where: { id },
|
|
include: {
|
|
customers: true,
|
|
quotation_items: { orderBy: { position: "asc" } },
|
|
scope_sections: { orderBy: { position: "asc" } },
|
|
},
|
|
});
|
|
if (!quotation) return null;
|
|
|
|
// Fetch linked order if exists
|
|
let orderInfo = null;
|
|
if (quotation.order_id) {
|
|
const order = await prisma.orders.findUnique({
|
|
where: { id: quotation.order_id },
|
|
select: { id: true, order_number: true, status: true },
|
|
});
|
|
orderInfo = order;
|
|
}
|
|
|
|
const { quotation_items, scope_sections, ...rest } = quotation;
|
|
return {
|
|
...rest,
|
|
items: quotation_items,
|
|
sections: scope_sections,
|
|
customer: quotation.customers,
|
|
customer_name: quotation.customers?.name || null,
|
|
order: orderInfo,
|
|
};
|
|
}
|
|
|
|
export async function createOffer(body: Record<string, any>) {
|
|
try {
|
|
return await prisma.$transaction(async (tx) => {
|
|
const quotationNumber =
|
|
body.quotation_number !== undefined && body.quotation_number !== null
|
|
? String(body.quotation_number)
|
|
: await generateOfferNumber(tx);
|
|
|
|
// Re-check uniqueness INSIDE the transaction to close the TOCTOU window
|
|
// between a pre-transaction check and the insert (mirrors createOrder).
|
|
// generateOfferNumber already verifies its own generated numbers, so this
|
|
// guards the caller-supplied path.
|
|
if (
|
|
body.quotation_number !== undefined &&
|
|
body.quotation_number !== null
|
|
) {
|
|
const taken = await isOfferNumberTaken(quotationNumber);
|
|
if (taken) {
|
|
throw Object.assign(new Error("Číslo nabídky je již použito"), {
|
|
status: 400,
|
|
});
|
|
}
|
|
}
|
|
|
|
const quotation = await tx.quotations.create({
|
|
data: {
|
|
quotation_number: quotationNumber,
|
|
project_code: body.project_code ? String(body.project_code) : null,
|
|
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
|
created_at: body.created_at
|
|
? new Date(String(body.created_at))
|
|
: undefined,
|
|
valid_until: body.valid_until
|
|
? new Date(String(body.valid_until))
|
|
: null,
|
|
currency: body.currency ? String(body.currency) : "CZK",
|
|
language: body.language ? String(body.language) : "cs",
|
|
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
|
apply_vat: body.apply_vat !== false,
|
|
status: body.status ? String(body.status) : "active",
|
|
scope_title: body.scope_title ? String(body.scope_title) : null,
|
|
scope_description: body.scope_description
|
|
? String(body.scope_description)
|
|
: null,
|
|
},
|
|
});
|
|
|
|
if (Array.isArray(body.items)) {
|
|
await tx.quotation_items.createMany({
|
|
data: (body.items as QuotationItemInput[]).map((item, i) => ({
|
|
quotation_id: quotation.id,
|
|
description: item.description ?? null,
|
|
item_description: item.item_description ?? null,
|
|
quantity: item.quantity ?? 1,
|
|
unit: item.unit ?? null,
|
|
unit_price: item.unit_price ?? 0,
|
|
is_included_in_total: item.is_included_in_total !== false,
|
|
position: item.position ?? i,
|
|
})),
|
|
});
|
|
}
|
|
|
|
if (Array.isArray(body.sections)) {
|
|
await tx.scope_sections.createMany({
|
|
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
|
|
quotation_id: quotation.id,
|
|
title: s.title ?? null,
|
|
title_cz: s.title_cz ?? null,
|
|
content: s.content ?? null,
|
|
position: s.position ?? i,
|
|
})),
|
|
});
|
|
}
|
|
|
|
return quotation;
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof Error && "status" in err) {
|
|
return {
|
|
error: err.message,
|
|
status: (err as Error & { status: number }).status,
|
|
} as const;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function updateOffer(id: number, body: Record<string, any>) {
|
|
const existing = await prisma.quotations.findUnique({ where: { id } });
|
|
if (!existing) return { error: "not_found" as const };
|
|
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;
|
|
}
|
|
|
|
const data = {
|
|
customer_id:
|
|
body.customer_id !== undefined ? Number(body.customer_id) : undefined,
|
|
created_at:
|
|
body.created_at !== undefined
|
|
? body.created_at
|
|
? new Date(String(body.created_at))
|
|
: null
|
|
: undefined,
|
|
valid_until:
|
|
body.valid_until !== undefined
|
|
? body.valid_until
|
|
? new Date(String(body.valid_until))
|
|
: null
|
|
: undefined,
|
|
currency: body.currency !== undefined ? String(body.currency) : undefined,
|
|
language: body.language !== undefined ? String(body.language) : undefined,
|
|
vat_rate: body.vat_rate !== undefined ? Number(body.vat_rate) : undefined,
|
|
apply_vat:
|
|
body.apply_vat !== undefined
|
|
? body.apply_vat === true ||
|
|
body.apply_vat === 1 ||
|
|
body.apply_vat === "1"
|
|
: undefined,
|
|
status: body.status !== undefined ? String(body.status) : undefined,
|
|
project_code:
|
|
body.project_code !== undefined
|
|
? body.project_code
|
|
? String(body.project_code)
|
|
: null
|
|
: undefined,
|
|
scope_title:
|
|
body.scope_title !== undefined
|
|
? body.scope_title
|
|
? String(body.scope_title)
|
|
: null
|
|
: undefined,
|
|
scope_description:
|
|
body.scope_description !== undefined
|
|
? body.scope_description
|
|
? String(body.scope_description)
|
|
: null
|
|
: undefined,
|
|
modified_at: new Date(),
|
|
};
|
|
|
|
if (Array.isArray(body.items) || Array.isArray(body.sections)) {
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.quotations.update({ where: { id }, data });
|
|
if (Array.isArray(body.items)) {
|
|
await tx.quotation_items.deleteMany({ where: { quotation_id: id } });
|
|
await tx.quotation_items.createMany({
|
|
data: (body.items as QuotationItemInput[]).map((item, i) => ({
|
|
quotation_id: id,
|
|
description: item.description ?? null,
|
|
item_description: item.item_description ?? null,
|
|
quantity: item.quantity ?? 1,
|
|
unit: item.unit ?? null,
|
|
unit_price: item.unit_price ?? 0,
|
|
is_included_in_total: item.is_included_in_total !== false,
|
|
position: item.position ?? i,
|
|
})),
|
|
});
|
|
}
|
|
if (Array.isArray(body.sections)) {
|
|
await tx.scope_sections.deleteMany({ where: { quotation_id: id } });
|
|
await tx.scope_sections.createMany({
|
|
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
|
|
quotation_id: id,
|
|
title: s.title ?? null,
|
|
title_cz: s.title_cz ?? null,
|
|
content: s.content ?? null,
|
|
position: s.position ?? i,
|
|
})),
|
|
});
|
|
}
|
|
});
|
|
} else {
|
|
await prisma.quotations.update({ where: { id }, data });
|
|
}
|
|
|
|
return { id, quotation_number: existing.quotation_number };
|
|
}
|
|
|
|
export async function deleteOffer(id: number) {
|
|
const existing = await prisma.quotations.findUnique({ where: { id } });
|
|
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, existing.quotation_number ?? undefined);
|
|
|
|
// Remove the offer PDF from the NAS so the DB delete doesn't orphan it.
|
|
// Best-effort and idempotent: deleteOfferPdf returns false (and logs) if the
|
|
// file is already gone, so a NAS outage / missing file never throws here.
|
|
if (existing.quotation_number && nasOffersManager.isConfigured()) {
|
|
try {
|
|
const relPath = nasOffersManager.buildRelativePath(
|
|
existing.quotation_number,
|
|
year,
|
|
);
|
|
nasOffersManager.deleteOfferPdf(relPath);
|
|
} catch (e) {
|
|
console.error(
|
|
"[offers.service] NAS offer PDF delete failed for",
|
|
existing.quotation_number,
|
|
e,
|
|
);
|
|
}
|
|
}
|
|
|
|
return existing;
|
|
}
|
|
|
|
export async function duplicateOffer(id: number) {
|
|
const original = await prisma.quotations.findUnique({
|
|
where: { id },
|
|
include: {
|
|
quotation_items: { orderBy: { position: "asc" } },
|
|
scope_sections: { orderBy: { position: "asc" } },
|
|
},
|
|
});
|
|
if (!original) return null;
|
|
|
|
return prisma.$transaction(async (tx) => {
|
|
const nextOfferNumber = await generateOfferNumber(tx);
|
|
|
|
const copy = await tx.quotations.create({
|
|
data: {
|
|
quotation_number: nextOfferNumber,
|
|
project_code: original.project_code,
|
|
customer_id: original.customer_id,
|
|
valid_until: null,
|
|
currency: original.currency,
|
|
language: original.language,
|
|
vat_rate: original.vat_rate,
|
|
apply_vat: original.apply_vat,
|
|
status: "active",
|
|
scope_title: original.scope_title,
|
|
scope_description: original.scope_description,
|
|
},
|
|
});
|
|
|
|
if (original.quotation_items.length > 0) {
|
|
await tx.quotation_items.createMany({
|
|
data: original.quotation_items.map((item) => ({
|
|
quotation_id: copy.id,
|
|
description: item.description,
|
|
item_description: item.item_description,
|
|
quantity: item.quantity,
|
|
unit: item.unit,
|
|
unit_price: item.unit_price,
|
|
is_included_in_total: item.is_included_in_total,
|
|
position: item.position,
|
|
})),
|
|
});
|
|
}
|
|
|
|
if (original.scope_sections.length > 0) {
|
|
await tx.scope_sections.createMany({
|
|
data: original.scope_sections.map((s) => ({
|
|
quotation_id: copy.id,
|
|
title: s.title,
|
|
title_cz: s.title_cz,
|
|
content: s.content,
|
|
position: s.position,
|
|
})),
|
|
});
|
|
}
|
|
|
|
return { copy, original };
|
|
});
|
|
}
|
|
|
|
export async function invalidateOffer(id: number) {
|
|
const existing = await prisma.quotations.findUnique({ where: { id } });
|
|
if (!existing) return null;
|
|
|
|
await prisma.quotations.update({
|
|
where: { id },
|
|
data: { status: "invalidated", modified_at: new Date() },
|
|
});
|
|
return existing;
|
|
}
|