import prisma from "../config/database"; import { generateSharedNumber, previewSharedNumber, releaseSharedNumber, isOrderNumberTaken, } from "./numbering.service"; 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", ]; function enrichOrder(o: any) { const subtotal = o.order_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 = o.apply_vat ? subtotal * ((o.vat_rate != null && o.vat_rate !== "" ? Number(o.vat_rate) : 21) / 100) : 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, subtotal: Math.round(subtotal * 100) / 100, vat_amount: Math.round(vatAmount * 100) / 100, total: Math.round((subtotal + vatAmount) * 100) / 100, }; } interface ListOrdersParams { page: number; limit: number; skip: number; sort: string; order: "asc" | "desc"; status?: string; customer_id?: number; } 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: Record = {}; if (params.status) where.status = params.status; if (params.customer_id) where.customer_id = params.customer_id; const [orders, total] = await Promise.all([ prisma.orders.findMany({ where, skip, take: limit, orderBy: { [sortField]: order }, 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 async function getOrder(id: number) { const order = await prisma.orders.findUnique({ where: { id }, 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" } }, }, }); 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; 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, customer_order_number: customerOrderNumber || null, quotation_id: quotationId, customer_id: quotation.customer_id, status: "prijata", currency: quotation.currency || "CZK", language: quotation.language || "cs", vat_rate: quotation.vat_rate ?? 21.0, apply_vat: quotation.apply_vat ?? true, exchange_rate: quotation.exchange_rate ?? 1.0, 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 || orderNumber, customer_id: quotation.customer_id, quotation_id: quotationId, order_id: order.id, status: "aktivni", }, }); return { order, project, orderNumber }; }); 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; vat_rate: number; apply_vat?: boolean; exchange_rate?: number; scope_title?: string | null; scope_description?: string | null; notes?: string | null; items?: OrderItemInput[]; sections?: OrderSectionInput[]; } export async function createOrder(body: CreateOrderData) { try { return 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, vat_rate: body.vat_rate, apply_vat: body.apply_vat !== false, exchange_rate: body.exchange_rate, scope_title: body.scope_title ?? null, scope_description: body.scope_description ?? null, notes: body.notes ?? 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, })), }); } return { id: order.id, order_number: order.order_number }; }); } 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) { const existing = await prisma.orders.findUnique({ where: { id } }); 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 (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate); if (body.apply_vat !== undefined) data.apply_vat = body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === "1"; 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) { const statusMap: Record = { v_realizaci: "aktivni", dokoncena: "dokonceny", stornovana: "zruseny", }; const projectStatus = statusMap[String(body.status)]; if (projectStatus) { await tx.projects.updateMany({ where: { order_id: id }, data: { status: projectStatus }, }); } } 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) { const statusMap: Record = { v_realizaci: "aktivni", dokoncena: "dokonceny", stornovana: "zruseny", }; const projectStatus = statusMap[String(body.status)]; if (projectStatus) { await prisma.projects.updateMany({ where: { order_id: id }, data: { status: projectStatus }, }); } } } return { data: { id, order_number: existing.order_number } }; } export async function deleteOrder(id: number) { const existing = await prisma.orders.findUnique({ where: { id } }); if (!existing) return { error: "Objednávka nenalezena", status: 404 } as const; // Fetch linked projects before the transaction for number release later const linkedProjects = await prisma.projects.findMany({ where: { order_id: id }, select: { id: true, created_at: true }, }); await prisma.$transaction(async (tx) => { // Clear quotation back-reference (matching PHP) 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 } }); }); const year = existing.created_at ? new Date(existing.created_at).getFullYear() : new Date().getFullYear(); await releaseSharedNumber(year, existing.order_number ?? undefined); return { data: { id, order_number: existing.order_number } }; } export async function getNextOrderNumber() { return previewSharedNumber(); }