import prisma from '../config/database'; import { generateSharedNumber } 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; 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(); }