From 0e9d30f5a8139938cb2b5b08bbfea5279bc2dd58 Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 23 Mar 2026 09:04:03 +0100 Subject: [PATCH] refactor: extract orders business logic into orders.service.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- src/routes/admin/orders.ts | 433 +++------------------------------ src/services/orders.service.ts | 384 +++++++++++++++++++++++++++++ 2 files changed, 424 insertions(+), 393 deletions(-) create mode 100644 src/services/orders.service.ts diff --git a/src/routes/admin/orders.ts b/src/routes/admin/orders.ts index 7a13c4a..e5ed63f 100644 --- a/src/routes/admin/orders.ts +++ b/src/routes/admin/orders.ts @@ -1,133 +1,64 @@ import { FastifyInstance } from 'fastify'; -import prisma from '../../config/database'; import { requirePermission } from '../../middleware/auth'; import { logAudit } from '../../services/audit'; import { success, error, parseId } from '../../utils/response'; import { parsePagination, buildPaginationMeta } from '../../utils/pagination'; import { parseBody } from '../../schemas/common'; import { CreateOrderFromQuotationSchema, CreateOrderSchema, UpdateOrderSchema } from '../../schemas/orders.schema'; -import { generateSharedNumber } from '../../services/numbering.service'; +import { + listOrders, + getOrder, + getOrderAttachment, + createOrderFromQuotation, + createOrder, + updateOrder, + deleteOrder, + getNextOrderNumber, +} from '../../services/orders.service'; import multipart from '@fastify/multipart'; -// Status transition rules matching PHP -const VALID_TRANSITIONS: Record = { - prijata: ['v_realizaci', 'stornovana'], - v_realizaci: ['dokoncena', 'stornovana'], - dokoncena: [], - stornovana: [], -}; - -interface OrderItemInput { description?: string; item_description?: string; quantity?: number; unit?: string; unit_price?: number; is_included_in_total?: boolean; position?: number } -interface OrderSectionInput { title?: string; title_cz?: string; content?: string; position?: number } - export default async function ordersRoutes(fastify: FastifyInstance): Promise { await fastify.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } }); // GET /api/admin/orders/next-number fastify.get('/next-number', { preHandler: requirePermission('orders.create') }, async (_request, reply) => { - const number = await generateSharedNumber(); + const number = await getNextOrderNumber(); return success(reply, { number, next_number: number }); }); - const ORDER_ALLOWED_SORT_FIELDS = ['id', 'order_number', 'status', 'currency', 'created_at']; - fastify.get('/', { preHandler: requirePermission('orders.view') }, async (request, reply) => { const query = request.query as Record; const { page, limit, skip, sort, order } = parsePagination(query); - const sortField = ORDER_ALLOWED_SORT_FIELDS.includes(sort) ? sort : 'id'; - const where: Record = {}; - if (query.status) where.status = String(query.status); - if (query.customer_id) where.customer_id = Number(query.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 }, - }, - }), - prisma.orders.count({ where }), - ]); - - const enriched = orders.map(o => { - const subtotal = o.order_items - .filter(i => i.is_included_in_total !== false) - .reduce((s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0); - const vatAmount = o.apply_vat ? subtotal * ((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, - }; + const result = await listOrders({ + page, limit, skip, sort, order, + status: query.status ? String(query.status) : undefined, + customer_id: query.customer_id ? Number(query.customer_id) : undefined, }); - return reply.send({ success: true, data: enriched, pagination: buildPaginationMeta(total, page, limit) }); + return reply.send({ success: true, data: result.data, pagination: buildPaginationMeta(result.total, result.page, result.limit) }); }); fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('orders.view') }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; - 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 }, take: 1 }, - }, - }); + const order = await getOrder(id); if (!order) return error(reply, 'Objednávka nenalezena', 404); - const { order_items, order_sections, ...rest } = order; - const invoice = order.invoices?.[0] || null; - return success(reply, { - ...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) || ''] || [], - }); + return success(reply, order); }); // GET /api/admin/orders/:id/attachment fastify.get<{ Params: { id: string } }>('/:id/attachment', { preHandler: requirePermission('orders.view') }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; - const order = await prisma.orders.findUnique({ - where: { id }, - select: { attachment_data: true, attachment_name: true }, - }); - if (!order?.attachment_data) return error(reply, 'Příloha nenalezena', 404); + const attachment = await getOrderAttachment(id); + if (!attachment) return error(reply, 'Příloha nenalezena', 404); - const filename = order.attachment_name || `order-${id}.pdf`; return reply .type('application/pdf') - .header('Content-Disposition', `inline; filename="${filename}"`) - .send(Buffer.from(order.attachment_data)); + .header('Content-Disposition', `inline; filename="${attachment.filename}"`) + .send(attachment.data); }); // POST /api/admin/orders — handles both JSON (manual) and multipart (from quotation) @@ -135,7 +66,7 @@ export default async function ordersRoutes(fastify: FastifyInstance): Promise = {}; let attachmentBuffer: Buffer | null = null; let attachmentName: string | null = null; @@ -157,93 +88,11 @@ export default async function ordersRoutes(fastify: FastifyInstance): Promise { - // Create the order - 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, - }, - }); - - // Copy quotation_items → order_items - 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, - })), - }); - } - - // Copy scope_sections → order_sections - 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, - })), - }); - } - - // Link quotation back to order and mark as ordered - await tx.quotations.update({ - where: { id: quotationId }, - data: { order_id: order.id, status: 'ordered', modified_at: new Date() }, - }); - - // Create project automatically - 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 }; - }); - - await logAudit({ request, authData: request.authData, action: 'create', entityType: 'order', entityId: result.order.id, description: `Vytvořena objednávka ${orderNumber} z nabídky #${quotationId}` }); - return success(reply, { order_id: result.order.id, id: result.order.id, order_number: orderNumber }, 201, 'Objednávka byla vytvořena'); + await logAudit({ request, authData: request.authData, action: 'create', entityType: 'order', entityId: result.data.order_id, description: `Vytvořena objednávka ${result.data.order_number} z nabídky #${result.data.quotationId}` }); + return success(reply, { order_id: result.data.order_id, id: result.data.id, order_number: result.data.order_number }, 201, 'Objednávka byla vytvořena'); } // === JSON body — either from-quotation (no attachment) or manual order === @@ -260,86 +109,11 @@ export default async function ordersRoutes(fastify: FastifyInstance): Promise { - 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, - }, - }); - - 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 }; - }); - - await logAudit({ request, authData: request.authData, action: 'create', entityType: 'order', entityId: result.order.id, description: `Vytvořena objednávka ${orderNumber} z nabídky #${quotationId}` }); - return success(reply, { order_id: result.order.id, id: result.order.id, order_number: orderNumber }, 201, 'Objednávka byla vytvořena'); + await logAudit({ request, authData: request.authData, action: 'create', entityType: 'order', entityId: result.data.order_id, description: `Vytvořena objednávka ${result.data.order_number} z nabídky #${result.data.quotationId}` }); + return success(reply, { order_id: result.data.order_id, id: result.data.id, order_number: result.data.order_number }, 201, 'Objednávka byla vytvořena'); } // Manual order creation @@ -347,53 +121,10 @@ export default async function ordersRoutes(fastify: FastifyInstance): Promise ({ - 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 prisma.order_sections.createMany({ - data: (body.sections as OrderSectionInput[]).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, - })), - }); - } - - await logAudit({ request, authData: request.authData, action: 'create', entityType: 'order', entityId: order.id, description: `Vytvořena objednávka ${order.order_number}` }); - return success(reply, { id: order.id }, 201, 'Objednávka byla vytvořena'); + await logAudit({ request, authData: request.authData, action: 'create', entityType: 'order', entityId: result.id, description: `Vytvořena objednávka ${result.order_number}` }); + return success(reply, { id: result.id }, 201, 'Objednávka byla vytvořena'); }); fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('orders.edit') }, async (request, reply) => { @@ -401,106 +132,22 @@ export default async function ordersRoutes(fastify: FastifyInstance): Promise = { modified_at: new Date() }; - const strFields = ['order_number', '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'; - - await prisma.orders.update({ where: { id }, data }); - - // Sync project_number when order_number changes (matching PHP) - if (body.order_number !== undefined && String(body.order_number) !== existing.order_number) { - await prisma.projects.updateMany({ - where: { order_id: id }, - data: { project_number: String(body.order_number) }, - }); - } - - // 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 }, - }); - } - } - - if (Array.isArray(body.items) || Array.isArray(body.sections)) { - await prisma.$transaction(async (tx) => { - 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, - })), - }); - } - }); - } - - await logAudit({ request, authData: request.authData, action: 'update', entityType: 'order', entityId: id, description: `Upravena objednávka ${existing.order_number}` }); + await logAudit({ request, authData: request.authData, action: 'update', entityType: 'order', entityId: id, description: `Upravena objednávka ${result.data.order_number}` }); return success(reply, { id }, 200, 'Objednávka byla uložena'); }); fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('orders.delete') }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; - const existing = await prisma.orders.findUnique({ where: { id } }); - if (!existing) return error(reply, 'Objednávka nenalezena', 404); - // Clear quotation back-reference (matching PHP) - await prisma.quotations.updateMany({ - where: { order_id: id }, - data: { order_id: null }, - }); + const result = await deleteOrder(id); + if ('error' in result) return error(reply, result.error, result.status); - // Delete linked project and its notes (matching PHP) - const linkedProjects = await prisma.projects.findMany({ where: { order_id: id }, select: { id: true } }); - if (linkedProjects.length > 0) { - const projectIds = linkedProjects.map(p => p.id); - await prisma.project_notes.deleteMany({ where: { project_id: { in: projectIds } } }); - await prisma.projects.deleteMany({ where: { order_id: id } }); - } - - await prisma.orders.delete({ where: { id } }); - await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'order', entityId: id, description: `Smazána objednávka ${existing.order_number}` }); + await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'order', entityId: id, description: `Smazána objednávka ${result.data.order_number}` }); return success(reply, null, 200, 'Objednávka smazána'); }); } diff --git a/src/services/orders.service.ts b/src/services/orders.service.ts new file mode 100644 index 0000000..365e3c0 --- /dev/null +++ b/src/services/orders.service.ts @@ -0,0 +1,384 @@ +import prisma from '../config/database'; +import { generateSharedNumber } from './numbering.service'; + +interface OrderItemInput { description?: string; item_description?: string; quantity?: number; unit?: string; unit_price?: number; is_included_in_total?: boolean; position?: number } +interface OrderSectionInput { title?: string; title_cz?: string; content?: string; 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 * ((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 }, + }, + }), + 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 }, take: 1 }, + }, + }); + 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 orderNumber = await generateSharedNumber(); + const projectNumber = await generateSharedNumber(); + + const result = await prisma.$transaction(async (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', + 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 }; + }); + + return { data: { order_id: result.order.id, id: result.order.id, order_number: 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) { + const order = await prisma.orders.create({ + data: { + order_number: body.order_number ?? null, + 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 prisma.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 prisma.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 }; +} + +interface UpdateOrderData { + [key: string]: unknown; + 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; + + // Validate status transition + 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 = ['order_number', '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'; + + await prisma.orders.update({ where: { id }, data }); + + // Sync project_number when order_number changes (matching PHP) + if (body.order_number !== undefined && String(body.order_number) !== existing.order_number) { + await prisma.projects.updateMany({ + where: { order_id: id }, + data: { project_number: String(body.order_number) }, + }); + } + + // 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 }, + }); + } + } + + if (Array.isArray(body.items) || Array.isArray(body.sections)) { + await prisma.$transaction(async (tx) => { + 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, + })), + }); + } + }); + } + + 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; + + // Clear quotation back-reference (matching PHP) + await prisma.quotations.updateMany({ + where: { order_id: id }, + data: { order_id: null }, + }); + + // Delete linked project and its notes (matching PHP) + const linkedProjects = await prisma.projects.findMany({ where: { order_id: id }, select: { id: true } }); + if (linkedProjects.length > 0) { + const projectIds = linkedProjects.map(p => p.id); + await prisma.project_notes.deleteMany({ where: { project_id: { in: projectIds } } }); + await prisma.projects.deleteMany({ where: { order_id: id } }); + } + + await prisma.orders.delete({ where: { id } }); + return { data: { id, order_number: existing.order_number } }; +} + +export async function getNextOrderNumber() { + return generateSharedNumber(); +}