import { Prisma } from "@prisma/client"; import prisma from "../config/database"; import { generateOfferNumber, 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"; /** * Offer status lifecycle (mirrors issued orders' table): a draft must be * finalized to `active` first (that's when the number is assigned), an active * offer can be ordered or invalidated, an ordered one only invalidated, and * `invalidated` is terminal. The create-order flow (orders.service * createOrderFromQuotation) consults this table too — an offer can only become * `ordered` from a status that allows the transition. */ export const VALID_TRANSITIONS: Record = { draft: ["active"], active: ["ordered", "invalidated"], ordered: ["invalidated"], invalidated: [], }; /** Fields (non-status) are editable only while the offer is draft/active. */ function isEditableStatus(status: string): boolean { return status === "draft" || status === "active"; } /** * Non-status update fields — used by the editable-state guard so a field edit * on an ordered/invalidated offer is rejected EXPLICITLY (not silently * dropped). `quotation_number` is stripped by the schema and is not listed. */ const NON_STATUS_UPDATE_FIELDS = [ "project_code", "customer_id", "created_at", "valid_until", "currency", "language", "scope_title", "scope_description", "items", "sections", ] as const; 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 { const { search, status, customer_id } = params; const where: Record = {}; 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; } // Offers are NOT tax documents — totals are NET only (no VAT anywhere). function enrichQuotation(q: any) { const total = 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 { quotation_items, scope_sections, ...rest } = q; return { ...rest, items: quotation_items, sections: scope_sections, customer_name: q.customers?.name || null, total: Math.round(total * 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); // Tiebreak on id: created_at is second-precision, so same-second rows would // otherwise paginate non-deterministically (project ordering rule). const orderBy: Record[] = [ { [sortField]: order }, { id: order }, ]; const [quotations, total] = await Promise.all([ prisma.quotations.findMany({ where, skip, take: limit, orderBy, include: { customers: { select: { id: true, name: true } }, quotation_items: { orderBy: { position: "asc" } }, // id tiebreak: position can repeat, keep the order deterministic. scope_sections: { orderBy: [{ position: "asc" }, { id: "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(); 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 NET total 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); // Only quotation_items — the per-currency NET math needs nothing else // (customers/scope_sections would just inflate the full-set scan). const quotations = await prisma.quotations.findMany({ where, include: { quotation_items: true, }, }); const byCurrency: Record = {}; 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" } }, // id tiebreak: position can repeat, keep the order deterministic. scope_sections: { orderBy: [{ position: "asc" }, { id: "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, // Computed server-side so the frontend renders only legal status buttons // (same contract as getIssuedOrder). valid_transitions: VALID_TRANSITIONS[quotation.status] || [], }; } export async function createOffer(body: Record) { 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; // Validate the referenced customer exists BEFORE the insert — a dangling // FK would otherwise surface as a P2003 500 instead of a clean 400 // (mirrors createIssuedOrder's supplier check). const customerId = body.customer_id ? Number(body.customer_id) : null; if (customerId) { const customer = await tx.customers.findUnique({ where: { id: customerId }, select: { id: true }, }); if (!customer) return { error: "customer_not_found" as const }; } // 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, tx); if (taken) return { error: "number_taken" as const }; } const quotation = await tx.quotations.create({ data: { quotation_number: quotationNumber, project_code: body.project_code ? String(body.project_code) : null, customer_id: customerId, 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", 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) { // P2002 on the quotation_number unique index = a concurrent insert won the // race between the in-tx check and our insert — surface the same clean // conflict token instead of a 500 (mirrors createIssuedOrder). if ( err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002" ) { return { error: "number_taken" as const }; } throw err; } } export async function updateOffer(id: number, body: Record) { const existing = await prisma.quotations.findUnique({ where: { id } }); if (!existing) return { error: "not_found" as const }; const currentStatus = existing.status; // Status changes must follow the transition table (issued-orders parity). if (body.status !== undefined && String(body.status) !== currentStatus) { const newStatus = String(body.status); const allowed = VALID_TRANSITIONS[currentStatus] || []; if (!allowed.includes(newStatus)) { return { error: "invalid_transition" as const, currentStatus, newStatus }; } } // Editable-state guard: non-status fields may change only in draft/active. // In ordered/invalidated a field edit is REJECTED explicitly (it used to be // possible to rewrite everything in any status except invalidated). // Status-only transition payloads (e.g. ordered -> invalidated) still work. const editable = isEditableStatus(currentStatus); if ( !editable && NON_STATUS_UPDATE_FIELDS.some((f) => body[f] !== undefined) ) { return { error: "not_editable" as const, currentStatus }; } // Explicit null CLEARS the customer; a set id must exist (dangling FK would // surface as a P2003 500). The old `Number(null)` coerced null to 0. let customerId: number | null | undefined = undefined; if (body.customer_id !== undefined) { customerId = body.customer_id ? Number(body.customer_id) : null; if (customerId) { const customer = await prisma.customers.findUnique({ where: { id: customerId }, select: { id: true }, }); if (!customer) return { error: "customer_not_found" as const }; } } // Finalize transition draft -> active: assign the official number ATOMICALLY // with the status change. assignOfferNumber is idempotent. const finalizing = currentStatus === "draft" && body.status !== undefined && String(body.status) === "active"; const data = { customer_id: customerId, 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, 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 }); } // newValues for the audit diff: the header fields actually written plus the // replaced collections (JSON.stringify drops the undefined = untouched keys). const newValues: Record = { ...data }; if (Array.isArray(body.items)) newValues.items = body.items; if (Array.isArray(body.sections)) newValues.sections = body.sections; // 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, oldValues: existing as unknown as Record, newValues, }; } export async function deleteOffer(id: number) { const existing = await prisma.quotations.findUnique({ where: { id } }); if (!existing) return { error: "not_found" as const }; try { await prisma.quotations.delete({ where: { id } }); } catch (err) { // P2003 = the offer is referenced by an order/project via a Restrict FK — // surface a clean conflict token instead of a 500. if ( err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2003" ) { return { error: "linked" as const }; } throw err; } 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. // The service is the SINGLE owner of this cleanup (the route used to repeat // it and always logged a spurious second-delete failure). // 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 { data: existing }; } export async function duplicateOffer(id: number) { const original = await prisma.quotations.findUnique({ where: { id }, include: { quotation_items: { orderBy: { position: "asc" } }, // id tiebreak: position can repeat, keep the order deterministic. scope_sections: { orderBy: [{ position: "asc" }, { id: "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, 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 { error: "not_found" as const }; // The dedicated endpoint must respect the same transition table as PUT: // only active/ordered offers can be invalidated (a draft is deleted instead, // invalidated is terminal). const allowed = VALID_TRANSITIONS[existing.status] || []; if (!allowed.includes("invalidated")) { return { error: "invalid_transition" as const, currentStatus: existing.status, newStatus: "invalidated", }; } await prisma.quotations.update({ where: { id }, data: { status: "invalidated", modified_at: new Date() }, }); return { data: existing }; }