Files
app/src/services/offers.service.ts
BOHA 97101c4db7 feat(offers,invoices): per-currency 'Celkem' totals beneath the lists (consistent with orders)
Mirrors the orders totals exactly: /stats (offers) + /list-totals (issued +
received invoices) endpoints sum each doc's total per currency across the active
filter (reusing extracted where-builders so they stay in sync with the lists),
rendered as the identical 'Celkem: …' block via the shared formatMultiCurrency.
Existing invoice/received KPI stats untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:12:24 +02:00

531 lines
17 KiB
TypeScript

import prisma from "../config/database";
import {
generateOfferNumber,
previewOfferNumber,
releaseOfferNumber,
isOfferNumberTaken,
assignOfferNumber,
} 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 OfferFilterParams {
search?: string;
status?: string;
customer_id?: number;
}
interface ListOffersParams extends OfferFilterParams {
page: number;
limit: number;
skip: number;
sort: string;
order: "asc" | "desc";
search: string;
}
export interface CurrencyAmount {
amount: number;
currency: string;
}
/**
* Shared `where` builder for the offers list and the per-currency stats
* aggregation, so both stay in sync (any new filter applies to both). Offers
* have NO month/year filter — the list filters by search + status + customer_id.
*/
function buildOfferWhere(params: OfferFilterParams): Record<string, unknown> {
const { search, status, customer_id } = params;
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 } } },
];
}
return where;
}
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 } = params;
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
const where = buildOfferWhere(params);
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 };
}
/**
* Sum offer TOTAL (incl. VAT) per currency across the WHOLE filtered set (not a
* single page). Reuses `buildOfferWhere` so the filters track the list, and
* `enrichQuotation` so the per-offer math matches the list/detail exactly.
* Returns one entry per currency, rounded to 2dp, zero/empty totals dropped.
*/
export async function getOfferTotals(
params: OfferFilterParams,
): Promise<{ totals: CurrencyAmount[] }> {
const where = buildOfferWhere(params);
const quotations = await prisma.quotations.findMany({
where,
include: {
customers: { select: { id: true, name: true } },
quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: { position: "asc" } },
},
});
const byCurrency: Record<string, number> = {};
for (const q of quotations) {
const { total, currency } = enrichQuotation(q);
const cur = currency || "CZK";
byCurrency[cur] = (byCurrency[cur] || 0) + (Number(total) || 0);
}
const totals: CurrencyAmount[] = Object.entries(byCurrency)
.map(([currency, amount]) => ({
currency,
amount: Math.round(amount * 100) / 100,
}))
.filter((t) => t.amount > 0);
return { totals };
}
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 status = body.status ? String(body.status) : "draft";
const explicitNumber =
body.quotation_number !== undefined && body.quotation_number !== null
? String(body.quotation_number)
: null;
// Deferred numbering: a draft carries NO quotation_number (the column is
// nullable-unique so many drafts coexist). The official number is consumed
// only when the draft is finalized (assignOfferNumber on draft->active).
// A caller-supplied number, or an offer created already-finalized, numbers now.
const quotationNumber =
explicitNumber ??
(status === "draft" ? null : 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 (explicitNumber !== null) {
const taken = await isOfferNumberTaken(explicitNumber);
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,
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 };
// A finalized offer's number is immutable. A draft has no number yet
// (quotation_number === null) — it is assigned on finalize, not editable here,
// so an empty/absent submitted value on a draft is not a "change" and must not
// block the draft->active finalize.
if (
existing.quotation_number !== null &&
body.quotation_number !== undefined &&
String(body.quotation_number) !== existing.quotation_number
) {
return { error: "Číslo nabídky nelze změnit", status: 400 } as const;
}
// Finalize transition draft -> active: assign the official number ATOMICALLY
// with the status change. assignOfferNumber is idempotent.
const finalizing =
existing.status === "draft" &&
body.status !== undefined &&
String(body.status) === "active";
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(),
};
let assignedNumber: string | null = null;
if (Array.isArray(body.items) || Array.isArray(body.sections) || finalizing) {
await prisma.$transaction(async (tx) => {
await tx.quotations.update({ where: { id }, data });
if (finalizing) assignedNumber = await assignOfferNumber(id, tx);
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 });
}
// Reflect the number the finalize transition just assigned (existing was read
// before the assign, so its quotation_number is still the pre-finalize null).
return {
id,
quotation_number: assignedNumber ?? 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;
}