refactor: extract orders business logic into orders.service.ts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string[]> = {
|
||||
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<void> {
|
||||
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<string, unknown>;
|
||||
const { page, limit, skip, sort, order } = parsePagination(query);
|
||||
const sortField = ORDER_ALLOWED_SORT_FIELDS.includes(sort) ? sort : 'id';
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
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<vo
|
||||
const isMultipart = request.headers['content-type']?.includes('multipart');
|
||||
|
||||
if (isMultipart) {
|
||||
// === Order from quotation flow ===
|
||||
// === Order from quotation flow (multipart) ===
|
||||
const fields: Record<string, string> = {};
|
||||
let attachmentBuffer: Buffer | null = null;
|
||||
let attachmentName: string | null = null;
|
||||
@@ -157,93 +88,11 @@ export default async function ordersRoutes(fastify: FastifyInstance): Promise<vo
|
||||
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' } },
|
||||
},
|
||||
});
|
||||
const result = await createOrderFromQuotation({ quotationId, customerOrderNumber, attachmentBuffer, attachmentName });
|
||||
if ('error' in result) return error(reply, result.error, result.status);
|
||||
|
||||
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 generateSharedNumber();
|
||||
const projectNumber = await generateSharedNumber();
|
||||
|
||||
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');
|
||||
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<vo
|
||||
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' } },
|
||||
},
|
||||
});
|
||||
const result = await createOrderFromQuotation({ quotationId, customerOrderNumber });
|
||||
if ('error' in result) return error(reply, result.error, result.status);
|
||||
|
||||
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 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,
|
||||
},
|
||||
});
|
||||
|
||||
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<vo
|
||||
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,
|
||||
},
|
||||
});
|
||||
const result = await createOrder(body);
|
||||
|
||||
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');
|
||||
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<vo
|
||||
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 result = await updateOrder(id, parsed.data);
|
||||
if ('error' in result) return error(reply, result.error, result.status);
|
||||
|
||||
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<string, unknown> = { 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<string, string> = {
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
||||
384
src/services/orders.service.ts
Normal file
384
src/services/orders.service.ts
Normal file
@@ -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<string, string[]> = {
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown> = { 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<string, string> = {
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user