import prisma from "../config/database"; import { Prisma } from "@prisma/client"; import { generateSharedNumber, previewSharedNumber, releaseSharedNumber, isOrderNumberTaken, generateProjectNumber, releaseProjectNumber, } from "./numbering.service"; import { NasFileManager } from "./nas-file-manager"; import { VALID_TRANSITIONS as OFFER_VALID_TRANSITIONS } from "./offers.service"; // Best-effort NAS folder creation for order-spawned projects, mirroring // projects.service. Stateless singleton; reads NAS_PATH at construction. const nasFileManager = new NasFileManager(); interface OrderItemInput { description?: string | null; item_description?: string | null; quantity?: number; unit?: string | null; unit_price?: number; is_included_in_total?: boolean; position?: number; } interface OrderSectionInput { title?: string | null; title_cz?: string | null; content?: string | null; position?: number; } // Status transition rules matching PHP export const VALID_TRANSITIONS: Record = { prijata: ["v_realizaci", "stornovana"], v_realizaci: ["dokoncena", "stornovana"], dokoncena: [], stornovana: [], }; const ORDER_ALLOWED_SORT_FIELDS = [ "id", "order_number", "status", "currency", "created_at", ]; // Order status -> linked-project status (matching PHP). const ORDER_TO_PROJECT_STATUS: Record = { v_realizaci: "aktivni", dokoncena: "dokonceny", stornovana: "zruseny", }; /** * Propagate an order status change onto its linked project(s). No-op when the * new status has no project-status mapping. Accepts a Prisma client (the tx * client inside a transaction, or the base client otherwise) so both update * branches share one implementation. */ async function syncProjectStatus( client: Prisma.TransactionClient, orderId: number, newStatus: string, ): Promise { const projectStatus = ORDER_TO_PROJECT_STATUS[newStatus]; if (!projectStatus) return; await client.projects.updateMany({ where: { order_id: orderId }, data: { status: projectStatus }, }); } // ⚠ Also called by getOrderTotals with a MINIMAL select (currency, item // quantity/unit_price/is_included_in_total). If you read a NEW order/item // field here, add it to that select — a field missing from the select is // silently 0 in the per-currency totals. // // Orders are NOT tax documents — totals are NET only (no VAT anywhere). // Structural shape for enrichOrder — like enrichQuotation it runs over several // different selects (full include vs. the minimal totals select), so only the // fields actually read are pinned; everything else is spread through. type EnrichableLineItem = { is_included_in_total?: unknown; quantity?: unknown; unit_price?: unknown; }; type EnrichableOrder = { order_items: EnrichableLineItem[]; order_sections?: unknown; invoices?: Array<{ id?: number | null; invoice_number?: string | null; }> | null; customers?: { name?: string | null } | null; quotations?: { quotation_number?: string | null; project_code?: string | null; } | null; [key: string]: unknown; }; // Generic over the concrete payload so `...rest` keeps every field the caller's // select actually returned (the return type must stay as rich as the input). function enrichOrder(o: T) { const total = o.order_items .filter((i) => i.is_included_in_total !== false) .reduce( (s: number, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0, ); const { order_items, order_sections, ...rest } = o; const invoice = o.invoices?.[0] || null; return { ...rest, items: order_items, sections: order_sections, customer_name: o.customers?.name || null, quotation_number: o.quotations?.quotation_number || null, project_code: o.quotations?.project_code || null, invoice_id: invoice?.id || null, invoice_number: invoice?.invoice_number || null, total: Math.round(total * 100) / 100, }; } interface OrderFilterParams { status?: string; customer_id?: number; search?: string; month?: number; year?: number; } interface ListOrdersParams extends OrderFilterParams { page: number; limit: number; skip: number; sort: string; order: "asc" | "desc"; } /** * Shared `where` builder for the orders list and the per-currency stats * aggregation, so both stay in sync (any new filter applies to both). month/year * filters on created_at (matching the list). */ function buildOrderWhere(params: OrderFilterParams): Record { const { status, customer_id, search, month, year } = params; const where: Record = {}; if (status) where.status = status; if (customer_id) where.customer_id = customer_id; if (search) { where.OR = [ { order_number: { contains: search } }, { customer_order_number: { contains: search } }, { customers: { name: { contains: search } } }, ]; } if (month && year) { const from = new Date(year, month - 1, 1); const to = new Date(year, month, 1); where.created_at = { gte: from, lt: to }; } return where; } export async function listOrders(params: ListOrdersParams) { const { page, limit, skip, order } = params; const sortField = ORDER_ALLOWED_SORT_FIELDS.includes(params.sort) ? params.sort : "id"; const where = buildOrderWhere(params); const [orders, total] = await Promise.all([ prisma.orders.findMany({ where, skip, take: limit, // Tiebreak on id so same-second created_at rows sort deterministically. orderBy: [{ [sortField]: order }, { id: order }], // The PO attachment blob is served ONLY by GET /:id/attachment. Never // pull it into list payloads — a single 1MB PDF would serialize into // megabytes of JSON per row. attachment_name stays as the existence // signal the frontend uses. omit: { attachment_data: true }, include: { customers: { select: { id: true, name: true } }, order_items: { orderBy: { position: "asc" } }, order_sections: { orderBy: { position: "asc" } }, quotations: { select: { quotation_number: true, project_code: true } }, invoices: { select: { id: true, invoice_number: true }, take: 1, orderBy: { id: "desc" }, }, }, }), prisma.orders.count({ where }), ]); const enriched = orders.map(enrichOrder); return { data: enriched, total, page, limit }; } export interface CurrencyAmount { amount: number; currency: string; } /** * Sum order NET total per currency across the WHOLE filtered set (not a * single page). Reuses `buildOrderWhere` so the filters track the list, and * `enrichOrder` so the per-order math matches the list/detail exactly. Returns * one entry per currency, rounded to 2dp, zero/empty totals dropped. */ export async function getOrderTotals( params: OrderFilterParams, ): Promise<{ totals: CurrencyAmount[] }> { const where = buildOrderWhere(params); // Select ONLY what the math needs (currency + item numbers). // This runs over the WHOLE filtered set, so pulling attachment blobs, // sections HTML or relations here would multiply the query size for nothing. const orders = await prisma.orders.findMany({ where, select: { currency: true, order_items: { select: { quantity: true, unit_price: true, is_included_in_total: true, }, }, }, }); const byCurrency: Record = {}; for (const o of orders) { const { total, currency } = enrichOrder(o); 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 getOrder(id: number) { const order = await prisma.orders.findUnique({ where: { id }, // The blob belongs only to GET /:id/attachment — the detail payload // carries attachment_name as the existence signal. omit: { attachment_data: true }, include: { customers: true, order_items: { orderBy: { position: "asc" } }, order_sections: { orderBy: { position: "asc" } }, quotations: { select: { id: true, quotation_number: true, project_code: true }, }, projects: { select: { id: true, project_number: true, name: true, status: true }, }, invoices: { select: { id: true, invoice_number: true, status: true }, orderBy: { id: "desc" }, }, }, }); if (!order) return null; const { order_items, order_sections, ...rest } = order; const invoice = order.invoices?.[0] || null; return { ...rest, items: order_items, sections: order_sections, customer: order.customers, customer_name: order.customers?.name || null, quotation_number: order.quotations?.quotation_number || null, project_code: order.quotations?.project_code || null, project: order.projects?.[0] || null, invoice: invoice, invoice_id: invoice?.id || null, invoice_number: invoice?.invoice_number || null, valid_transitions: VALID_TRANSITIONS[(order.status as string) || ""] || [], }; } export async function getOrderAttachment(id: number) { const order = await prisma.orders.findUnique({ where: { id }, select: { attachment_data: true, attachment_name: true }, }); if (!order?.attachment_data) return null; return { data: Buffer.from(order.attachment_data), filename: order.attachment_name || `order-${id}.pdf`, }; } interface CreateOrderFromQuotationData { quotationId: number; customerOrderNumber?: string; attachmentBuffer?: Buffer | null; attachmentName?: string | null; } export async function createOrderFromQuotation( data: CreateOrderFromQuotationData, ) { const { quotationId, customerOrderNumber, attachmentBuffer, attachmentName } = data; const quotation = await prisma.quotations.findUnique({ where: { id: quotationId }, include: { quotation_items: { orderBy: { position: "asc" } }, scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] }, }, }); if (!quotation) return { error: "Nabídka nenalezena", status: 404 } as const; if (quotation.order_id) return { error: "Z této nabídky již byla vytvořena objednávka", status: 400, } as const; // The flow sets the offer's status to `ordered`, so it must respect the // offers transition table: only an offer whose current status allows the // `ordered` transition qualifies. A draft must be finalized first (it has no // number yet — an ordered offer without a number must never exist) and an // invalidated offer is terminal. const allowedFromStatus = OFFER_VALID_TRANSITIONS[quotation.status] || []; if (!allowedFromStatus.includes("ordered")) { return { error: `Objednávku nelze vytvořit z nabídky ve stavu "${quotation.status}"`, status: 400, } as const; } const result = await prisma.$transaction(async (tx) => { const orderNumber = await generateSharedNumber(tx); // The order keeps the shared (order) sequence; the auto-created project // gets its OWN number from the dedicated project sequence. Both are // assigned inside this transaction so they're allocated atomically. const projectNumber = await generateProjectNumber(tx); const order = await tx.orders.create({ data: { order_number: orderNumber, customer_order_number: customerOrderNumber || null, quotation_id: quotationId, customer_id: quotation.customer_id, status: "prijata", currency: quotation.currency || "CZK", language: quotation.language || "cs", scope_title: quotation.scope_title, scope_description: quotation.scope_description, attachment_data: attachmentBuffer ? new Uint8Array(attachmentBuffer) : null, attachment_name: attachmentName || null, }, }); if (quotation.quotation_items.length > 0) { await tx.order_items.createMany({ data: quotation.quotation_items.map((item) => ({ order_id: order.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 (quotation.scope_sections.length > 0) { await tx.order_sections.createMany({ data: quotation.scope_sections.map((s) => ({ order_id: order.id, title: s.title, title_cz: s.title_cz, content: s.content, position: s.position, })), }); } await tx.quotations.update({ where: { id: quotationId }, data: { order_id: order.id, status: "ordered", modified_at: new Date() }, }); const project = await tx.projects.create({ data: { project_number: projectNumber, name: quotation.project_code || quotation.quotation_number || projectNumber, customer_id: quotation.customer_id, quotation_id: quotationId, order_id: order.id, status: "aktivni", }, }); return { order, project, orderNumber }; }); // Best-effort NAS project folder, outside the transaction (mirrors // createProject). A project created from an accepted offer must get the same // 02_PROJEKTY/_ folder as a manually-created one. Non-fatal: a // NAS outage must never roll back the order. if (result.project.project_number && nasFileManager.isConfigured()) { const created = nasFileManager.createProjectFolder( result.project.project_number, result.project.name || "", ); if (!created) { console.error( "[orders.service] NAS folder not created for project", result.project.project_number, ); } } return { data: { order_id: result.order.id, id: result.order.id, order_number: result.orderNumber, quotationId, }, }; } interface CreateOrderData { order_number?: string | null; customer_order_number?: string | null; quotation_id?: number | null; customer_id?: number | null; status: string; currency: string; language: string; exchange_rate?: number; scope_title?: string | null; scope_description?: string | null; notes?: string | null; items?: OrderItemInput[]; sections?: OrderSectionInput[]; create_project?: boolean; } export async function createOrder( body: CreateOrderData, attachmentBuffer?: Buffer | null, attachmentName?: string | null, ) { try { const result = await prisma.$transaction(async (tx) => { const orderNumber = body.order_number !== undefined && body.order_number !== null ? String(body.order_number) : await generateSharedNumber(tx); if (body.order_number !== undefined && body.order_number !== null) { const taken = await isOrderNumberTaken(String(body.order_number)); if (taken) { throw Object.assign(new Error("Číslo objednávky je již použito"), { status: 400, }); } } const order = await tx.orders.create({ data: { order_number: orderNumber, customer_order_number: body.customer_order_number ?? null, quotation_id: body.quotation_id ?? null, customer_id: body.customer_id ?? null, status: body.status, currency: body.currency, language: body.language, exchange_rate: body.exchange_rate, scope_title: body.scope_title ?? null, scope_description: body.scope_description ?? null, notes: body.notes ?? null, attachment_data: attachmentBuffer ? new Uint8Array(attachmentBuffer) : null, attachment_name: attachmentName || null, }, }); if (Array.isArray(body.items)) { await tx.order_items.createMany({ data: body.items.map((item, i) => ({ order_id: order.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.order_sections.createMany({ data: body.sections.map((s, i) => ({ order_id: order.id, title: s.title ?? null, title_cz: s.title_cz ?? null, content: s.content ?? null, position: s.position ?? i, })), }); } let projectId: number | undefined; let createdProject: { project_number: string; name: string } | null = null; // Only auto-create a project for genuine offer-less orders. The project // gets its OWN number from the dedicated project sequence (not the // order's shared number), exactly like the from-quotation flow. if (body.create_project !== false && body.quotation_id == null) { const projectNumber = await generateProjectNumber(tx); const project = await tx.projects.create({ data: { project_number: projectNumber, name: body.scope_title || body.customer_order_number || projectNumber, customer_id: order.customer_id, order_id: order.id, status: "aktivni", }, }); projectId = project.id; if (project.project_number) { createdProject = { project_number: project.project_number, name: project.name || "", }; } } return { id: order.id, order_number: order.order_number, project_id: projectId, createdProject, }; }); // Best-effort NAS project folder, outside the transaction (mirrors // createProject). Non-fatal so a NAS outage can't roll back the order. if (result.createdProject && nasFileManager.isConfigured()) { const created = nasFileManager.createProjectFolder( result.createdProject.project_number, result.createdProject.name, ); if (!created) { console.error( "[orders.service] NAS folder not created for project", result.createdProject.project_number, ); } } return { id: result.id, order_number: result.order_number, project_id: result.project_id, }; } catch (err) { if (err instanceof Error && "status" in err) { return { error: err.message, status: (err as Error & { status: number }).status, }; } throw err; } } interface UpdateOrderData { [key: string]: unknown; customer_id?: number | string | null; items?: OrderItemInput[]; sections?: OrderSectionInput[]; } export async function updateOrder(id: number, body: UpdateOrderData) { // Pre-read only the fields the guards below check — never the PDF blob. const existing = await prisma.orders.findUnique({ where: { id }, select: { status: true, order_number: true }, }); if (!existing) return { error: "Objednávka nenalezena", status: 404 } as const; 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] || []; if (!allowed.includes(newStatus)) { return { error: `Neplatný přechod stavu z "${currentStatus}" na "${newStatus}"`, status: 400, } as const; } } const data: Record = { modified_at: new Date() }; const strFields = [ "customer_order_number", "status", "currency", "language", "scope_title", "scope_description", "notes", ]; for (const f of strFields) { if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null; } if (body.customer_id !== undefined) data.customer_id = body.customer_id ? Number(body.customer_id) : null; 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; } if ( body.status !== undefined && (String(body.status) === "dokoncena" || String(body.status) === "stornovana") ) { return { error: "Nelze upravit položky při změně stavu na dokončeno/storno", status: 400, } as const; } await prisma.$transaction(async (tx) => { await tx.orders.update({ where: { id }, data }); // Sync project status when order status changes (matching PHP) if (body.status !== undefined && String(body.status) !== currentStatus) { await syncProjectStatus(tx, id, String(body.status)); } if (Array.isArray(body.items)) { await tx.order_items.deleteMany({ where: { order_id: id } }); await tx.order_items.createMany({ data: (body.items as OrderItemInput[]).map((item, i) => ({ order_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.order_sections.deleteMany({ where: { order_id: id } }); await tx.order_sections.createMany({ data: (body.sections as OrderSectionInput[]).map((s, i) => ({ order_id: id, title: s.title ?? null, title_cz: s.title_cz ?? null, content: s.content ?? null, position: s.position ?? i, })), }); } }); } else { await prisma.orders.update({ where: { id }, data }); // Sync project status when order status changes (matching PHP) if (body.status !== undefined && String(body.status) !== currentStatus) { await syncProjectStatus(prisma, id, String(body.status)); } } return { data: { id, order_number: existing.order_number } }; } export async function deleteOrder(id: number, deleteFiles = false) { // Pre-read only what the delete needs (number release + year) — not the blob. const existing = await prisma.orders.findUnique({ where: { id }, select: { order_number: true, created_at: true }, }); if (!existing) return { error: "Objednávka nenalezena", status: 404 } as const; // Fetch linked projects before the transaction for number release and // (optional) NAS folder cleanup later. project_number is needed to locate // the folder on the share. const linkedProjects = await prisma.projects.findMany({ where: { order_id: id }, select: { id: true, created_at: true, project_number: true }, }); try { await prisma.$transaction(async (tx) => { // Guard: projects may have non-cascaded warehouse refs (sklad_issues, // sklad_reservations, attendance_project_logs all use project_id as a // non-nullable FK with no onDelete). Surface as 409 (resource conflict) // rather than letting Prisma throw P2003 -> 500. The count check runs // INSIDE the transaction so a row can't be re-introduced between the // guard and the project delete (closes the narrow re-intro window). // // Only ACTIVE records count: a CANCELLED issue/restored batch or // CANCELLED reservation is audit-trail-only (cancelIssue() has already // restored the batch qty; cancelReservation() zeroes remaining_qty). // Attendance project logs are time-records — they always block, since // deleting the project would orphan the time record. The user must // re-assign the attendance log to a different project first. if (linkedProjects.length > 0) { const projectIds = linkedProjects.map((p) => p.id); const [activeIssuesCount, activeReservationsCount, attendanceLogCount] = await Promise.all([ tx.sklad_issues.count({ where: { project_id: { in: projectIds }, status: { not: "CANCELLED" }, }, }), tx.sklad_reservations.count({ where: { project_id: { in: projectIds }, status: { not: "CANCELLED" }, }, }), tx.attendance_project_logs.count({ where: { project_id: { in: projectIds } }, }), ]); if ( activeIssuesCount + activeReservationsCount + attendanceLogCount > 0 ) { throw Object.assign( new Error( "Nelze smazat objednávku, protože navázaný projekt má aktivní skladové výdeje, rezervace nebo docházkové záznamy. Zrušte je nebo přeřaďte docházku na jiný projekt.", ), { status: 409 }, ); } } // Clear the quotation back-reference AND revert its status: an // `ordered` offer with no order is a dead end (the transition table // only allows ordered -> invalidated), so it goes back to `active`, // making it orderable again. await tx.quotations.updateMany({ where: { order_id: id, status: "ordered" }, data: { order_id: null, status: "active", modified_at: new Date() }, }); // Backstop for rows in any other status (shouldn't exist — only an // ordered offer can hold an order_id). await tx.quotations.updateMany({ where: { order_id: id }, data: { order_id: null }, }); // Delete linked project and its notes (matching PHP) if (linkedProjects.length > 0) { const projectIds = linkedProjects.map((p) => p.id); await tx.project_notes.deleteMany({ where: { project_id: { in: projectIds } }, }); await tx.projects.deleteMany({ where: { order_id: id } }); } // Explicitly clean up child rows await tx.order_items.deleteMany({ where: { order_id: id } }); await tx.order_sections.deleteMany({ where: { order_id: id } }); await tx.orders.delete({ where: { id } }); }); } catch (err) { if (err instanceof Error && "status" in err) { return { error: err.message, status: (err as Error & { status: number }).status, } as const; } throw err; } // Best-effort NAS folder cleanup for the order's project(s), outside the // transaction and only when the user ticked the "delete folder" checkbox in // the order delete modal. Non-fatal: a NAS error must not undo the // already-committed DB delete. deleteProjectFolder is idempotent — it returns // true (no-op) when the folder is already absent. if (deleteFiles && nasFileManager.isConfigured()) { for (const p of linkedProjects) { if (!p.project_number) continue; const ok = await nasFileManager.deleteProjectFolder(p.project_number); if (!ok) { console.error( "[orders.service] NAS folder not deleted for project", p.project_number, ); } } } const year = existing.created_at ? new Date(existing.created_at).getFullYear() : new Date().getFullYear(); await releaseSharedNumber(year, existing.order_number ?? undefined); // Release each deleted project's OWN number from the dedicated `project` // sequence. Auto-created projects now draw a distinct number from the // `project` pool (not the order's `shared` number), so deleting the order // must release the project number too — otherwise the highest project number // leaves a permanent gap. Use each project's OWN created_at year: project // numbers embed {YY} and the sequence is (type, year)-keyed, so the order's // year could decrement the wrong row. Best-effort, post-commit, mirroring the // shared release above — releaseSequence row-locks and only decrements when // the deleted number is the current highest, so this is safe/idempotent. for (const p of linkedProjects) { if (!p.project_number) continue; const projYear = p.created_at ? new Date(p.created_at).getFullYear() : year; await releaseProjectNumber(projYear, p.project_number); } return { data: { id, order_number: existing.order_number } }; } export async function getNextOrderNumber() { return previewSharedNumber(); }