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 multipart from '@fastify/multipart'; // Status transition rules matching PHP const VALID_TRANSITIONS: Record = { prijata: ['v_realizaci', 'stornovana'], v_realizaci: ['dokoncena', 'stornovana'], dokoncena: [], stornovana: [], }; // Shared number generator matching PHP generateSharedNumber() // Format: YYtypeCode + 4-digit sequence, shared between orders and projects async function generateSharedNumber(): Promise { const settings = await prisma.company_settings.findFirst({ select: { order_type_code: true } }); const typeCode = settings?.order_type_code || '71'; const yy = String(new Date().getFullYear()).slice(-2); const prefix = `${yy}${typeCode}`; const prefixLen = prefix.length; const likePattern = `${prefix}%`; const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>` SELECT COALESCE(MAX(seq), 0) as max_seq FROM ( SELECT CAST(SUBSTRING(order_number, ${prefixLen} + 1) AS UNSIGNED) AS seq FROM orders WHERE order_number LIKE ${likePattern} UNION ALL SELECT CAST(SUBSTRING(project_number, ${prefixLen} + 1) AS UNSIGNED) AS seq FROM projects WHERE project_number LIKE ${likePattern} ) combined `; const nextNum = Number(result[0]?.max_seq ?? 0) + 1; return `${prefix}${String(nextNum).padStart(4, '0')}`; } async function generateOrderNumber(): Promise { return generateSharedNumber(); } async function generateProjectNumber(): Promise { return generateSharedNumber(); } 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 generateOrderNumber(); 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, }; }); return reply.send({ success: true, data: enriched, pagination: buildPaginationMeta(total, page, 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 }, }, }); 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) || ''] || [], }); }); // 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 filename = order.attachment_name || `order-${id}.pdf`; return reply .type('application/pdf') .header('Content-Disposition', `inline; filename="${filename}"`) .send(Buffer.from(order.attachment_data)); }); // POST /api/admin/orders — handles both JSON (manual) and multipart (from quotation) fastify.post('/', { preHandler: requirePermission('orders.create') }, async (request, reply) => { const isMultipart = request.headers['content-type']?.includes('multipart'); if (isMultipart) { // === Order from quotation flow === const fields: Record = {}; let attachmentBuffer: Buffer | null = null; let attachmentName: string | null = null; const parts = request.parts(); for await (const part of parts) { if (part.type === 'field') { fields[part.fieldname] = String(part.value); } else if (part.type === 'file' && part.fieldname === 'attachment') { attachmentBuffer = await part.toBuffer(); attachmentName = part.filename; } } const quotationId = parseInt(fields.quotationId, 10); const customerOrderNumber = fields.customerOrderNumber || ''; if (!quotationId || isNaN(quotationId)) { return error(reply, 'Chybí ID nabídky', 400); } const quotation = await prisma.quotations.findUnique({ where: { id: quotationId }, include: { quotation_items: { orderBy: { position: 'asc' } }, scope_sections: { orderBy: { position: 'asc' } }, }, }); if (!quotation) return error(reply, 'Nabídka nenalezena', 404); if (quotation.order_id) return error(reply, 'Z této nabídky již byla vytvořena objednávka', 400); const orderNumber = await generateOrderNumber(); const projectNumber = await generateProjectNumber(); const result = await prisma.$transaction(async (tx) => { // 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'); } // === JSON body — either from-quotation (no attachment) or manual order === const rawBody = request.body as Record; // From-quotation flow via JSON (no attachment) if (rawBody.quotationId) { const fromQuotParsed = parseBody(CreateOrderFromQuotationSchema, rawBody); if ('error' in fromQuotParsed) return error(reply, fromQuotParsed.error, 400); const quotationId = fromQuotParsed.data.quotationId; const customerOrderNumber = fromQuotParsed.data.customerOrderNumber; if (!quotationId || isNaN(quotationId)) { return error(reply, 'Chybí ID nabídky', 400); } const quotation = await prisma.quotations.findUnique({ where: { id: quotationId }, include: { quotation_items: { orderBy: { position: 'asc' } }, scope_sections: { orderBy: { position: 'asc' } }, }, }); if (!quotation) return error(reply, 'Nabídka nenalezena', 404); if (quotation.order_id) return error(reply, 'Z této nabídky již byla vytvořena objednávka', 400); const orderNumber = await generateOrderNumber(); const projectNumber = await generateProjectNumber(); 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, }, }); 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'); } // Manual order creation const manualParsed = parseBody(CreateOrderSchema, rawBody); if ('error' in manualParsed) return error(reply, manualParsed.error, 400); const body = manualParsed.data; 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 as OrderItemInput[]).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 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'); }); fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('orders.edit') }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const parsed = parseBody(UpdateOrderSchema, request.body); if ('error' in parsed) return error(reply, parsed.error, 400); const body = parsed.data; const existing = await prisma.orders.findUnique({ where: { id } }); if (!existing) return error(reply, 'Objednávka nenalezena', 404); 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(reply, `Neplatný přechod stavu z "${currentStatus}" na "${newStatus}"`, 400); } } 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, })), }); } }); } await logAudit({ request, authData: request.authData, action: 'update', entityType: 'order', entityId: id, description: `Upravena objednávka ${existing.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 }, }); // 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}` }); return success(reply, null, 200, 'Objednávka smazána'); }); }