Files
app/src/services/orders.service.ts
BOHA 82919d39f6 fix: remove manual project creation, smart sequence release, received-invoices schema fix
- Remove ProjectCreate page, POST /projects endpoint, and next-number endpoint
- Projects can only be created through orders (shared numbering sequence)
- Remove dead CreateProjectSchema and createProject service function
- Delete 'order' row from number_sequences (unused; code uses 'shared')
- Smart sequence release: decrement last_number only when deleting the highest number
- Fix received-invoices stats referencing non-existent is_deleted and amount_czk columns
- Update deploy instructions in CLAUDE.md (npm install, prisma migrate deploy)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 11:36:08 +02:00

556 lines
17 KiB
TypeScript

import prisma from "../config/database";
import {
generateSharedNumber,
previewSharedNumber,
releaseSharedNumber,
isOrderNumberTaken,
} from "./numbering.service";
interface OrderItemInput {
description?: string | null;
item_description?: string | null;
quantity?: number;
unit?: string | null;
unit_price?: number;
is_included_in_total?: boolean;
position?: number;
}
interface OrderSectionInput {
title?: string | null;
title_cz?: string | null;
content?: string | null;
position?: number;
}
// Status transition rules matching PHP
export const VALID_TRANSITIONS: Record<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 *
((o.vat_rate != null && o.vat_rate !== "" ? Number(o.vat_rate) : 21) /
100)
: 0;
const { order_items, order_sections, ...rest } = o;
const invoice = o.invoices?.[0] || null;
return {
...rest,
items: order_items,
sections: order_sections,
customer_name: o.customers?.name || null,
quotation_number: o.quotations?.quotation_number || null,
project_code: o.quotations?.project_code || null,
invoice_id: invoice?.id || null,
invoice_number: invoice?.invoice_number || null,
subtotal: Math.round(subtotal * 100) / 100,
vat_amount: Math.round(vatAmount * 100) / 100,
total: Math.round((subtotal + vatAmount) * 100) / 100,
};
}
interface ListOrdersParams {
page: number;
limit: number;
skip: number;
sort: string;
order: "asc" | "desc";
status?: string;
customer_id?: number;
}
export async function listOrders(params: ListOrdersParams) {
const { page, limit, skip, order } = params;
const sortField = ORDER_ALLOWED_SORT_FIELDS.includes(params.sort)
? params.sort
: "id";
const where: Record<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,
orderBy: { id: "desc" },
},
},
}),
prisma.orders.count({ where }),
]);
const enriched = orders.map(enrichOrder);
return { data: enriched, total, page, limit };
}
export async function getOrder(id: number) {
const order = await prisma.orders.findUnique({
where: { id },
include: {
customers: true,
order_items: { orderBy: { position: "asc" } },
order_sections: { orderBy: { position: "asc" } },
quotations: {
select: { id: true, quotation_number: true, project_code: true },
},
projects: {
select: { id: true, project_number: true, name: true, status: true },
},
invoices: {
select: { id: true, invoice_number: true, status: true },
orderBy: { id: "desc" },
},
},
});
if (!order) return null;
const { order_items, order_sections, ...rest } = order;
const invoice = order.invoices?.[0] || null;
return {
...rest,
items: order_items,
sections: order_sections,
customer: order.customers,
customer_name: order.customers?.name || null,
quotation_number: order.quotations?.quotation_number || null,
project_code: order.quotations?.project_code || null,
project: order.projects?.[0] || null,
invoice: invoice,
invoice_id: invoice?.id || null,
invoice_number: invoice?.invoice_number || null,
valid_transitions: VALID_TRANSITIONS[(order.status as string) || ""] || [],
};
}
export async function getOrderAttachment(id: number) {
const order = await prisma.orders.findUnique({
where: { id },
select: { attachment_data: true, attachment_name: true },
});
if (!order?.attachment_data) return null;
return {
data: Buffer.from(order.attachment_data),
filename: order.attachment_name || `order-${id}.pdf`,
};
}
interface CreateOrderFromQuotationData {
quotationId: number;
customerOrderNumber?: string;
attachmentBuffer?: Buffer | null;
attachmentName?: string | null;
}
export async function createOrderFromQuotation(
data: CreateOrderFromQuotationData,
) {
const { quotationId, customerOrderNumber, attachmentBuffer, attachmentName } =
data;
const quotation = await prisma.quotations.findUnique({
where: { id: quotationId },
include: {
quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: { position: "asc" } },
},
});
if (!quotation) return { error: "Nabídka nenalezena", status: 404 } as const;
if (quotation.order_id)
return {
error: "Z této nabídky již byla vytvořena objednávka",
status: 400,
} as const;
const result = await prisma.$transaction(async (tx) => {
const orderNumber = await generateSharedNumber(tx);
const projectNumber = orderNumber;
const order = await tx.orders.create({
data: {
order_number: orderNumber,
customer_order_number: customerOrderNumber || null,
quotation_id: quotationId,
customer_id: quotation.customer_id,
status: "prijata",
currency: quotation.currency || "CZK",
language: quotation.language || "cs",
vat_rate: quotation.vat_rate ?? 21.0,
apply_vat: quotation.apply_vat ?? true,
exchange_rate: quotation.exchange_rate ?? 1.0,
scope_title: quotation.scope_title,
scope_description: quotation.scope_description,
attachment_data: attachmentBuffer
? new Uint8Array(attachmentBuffer)
: null,
attachment_name: attachmentName || null,
},
});
if (quotation.quotation_items.length > 0) {
await tx.order_items.createMany({
data: quotation.quotation_items.map((item) => ({
order_id: order.id,
description: item.description,
item_description: item.item_description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
is_included_in_total: item.is_included_in_total,
position: item.position,
})),
});
}
if (quotation.scope_sections.length > 0) {
await tx.order_sections.createMany({
data: quotation.scope_sections.map((s) => ({
order_id: order.id,
title: s.title,
title_cz: s.title_cz,
content: s.content,
position: s.position,
})),
});
}
await tx.quotations.update({
where: { id: quotationId },
data: { order_id: order.id, status: "ordered", modified_at: new Date() },
});
const project = await tx.projects.create({
data: {
project_number: projectNumber,
name:
quotation.project_code || quotation.quotation_number || orderNumber,
customer_id: quotation.customer_id,
quotation_id: quotationId,
order_id: order.id,
status: "aktivni",
},
});
return { order, project, orderNumber };
});
return {
data: {
order_id: result.order.id,
id: result.order.id,
order_number: result.orderNumber,
quotationId,
},
};
}
interface CreateOrderData {
order_number?: string | null;
customer_order_number?: string | null;
quotation_id?: number | null;
customer_id?: number | null;
status: string;
currency: string;
language: string;
vat_rate: number;
apply_vat?: boolean;
exchange_rate?: number;
scope_title?: string | null;
scope_description?: string | null;
notes?: string | null;
items?: OrderItemInput[];
sections?: OrderSectionInput[];
}
export async function createOrder(body: CreateOrderData) {
try {
return await prisma.$transaction(async (tx) => {
const orderNumber =
body.order_number !== undefined && body.order_number !== null
? String(body.order_number)
: await generateSharedNumber(tx);
if (body.order_number !== undefined && body.order_number !== null) {
const taken = await isOrderNumberTaken(String(body.order_number));
if (taken) {
throw Object.assign(new Error("Číslo objednávky je již použito"), {
status: 400,
});
}
}
const order = await tx.orders.create({
data: {
order_number: orderNumber,
customer_order_number: body.customer_order_number ?? null,
quotation_id: body.quotation_id ?? null,
customer_id: body.customer_id ?? null,
status: body.status,
currency: body.currency,
language: body.language,
vat_rate: body.vat_rate,
apply_vat: body.apply_vat !== false,
exchange_rate: body.exchange_rate,
scope_title: body.scope_title ?? null,
scope_description: body.scope_description ?? null,
notes: body.notes ?? null,
},
});
if (Array.isArray(body.items)) {
await tx.order_items.createMany({
data: body.items.map((item, i) => ({
order_id: order.id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
is_included_in_total: item.is_included_in_total !== false,
position: item.position ?? i,
})),
});
}
if (Array.isArray(body.sections)) {
await tx.order_sections.createMany({
data: body.sections.map((s, i) => ({
order_id: order.id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
return { id: order.id, order_number: order.order_number };
});
} catch (err) {
if (err instanceof Error && "status" in err) {
return {
error: err.message,
status: (err as Error & { status: number }).status,
};
}
throw err;
}
}
interface UpdateOrderData {
[key: string]: unknown;
customer_id?: number | string | null;
items?: OrderItemInput[];
sections?: OrderSectionInput[];
}
export async function updateOrder(id: number, body: UpdateOrderData) {
const existing = await prisma.orders.findUnique({ where: { id } });
if (!existing)
return { error: "Objednávka nenalezena", status: 404 } as const;
const currentStatus = existing.status as string;
if (
body.order_number !== undefined &&
String(body.order_number) !== existing.order_number
) {
return {
error: "Číslo objednávky nelze změnit",
status: 400,
} as const;
}
if (body.status !== undefined && String(body.status) !== currentStatus) {
const newStatus = String(body.status);
const allowed = VALID_TRANSITIONS[currentStatus] || [];
if (!allowed.includes(newStatus)) {
return {
error: `Neplatný přechod stavu z "${currentStatus}" na "${newStatus}"`,
status: 400,
} as const;
}
}
const data: Record<string, unknown> = { modified_at: new Date() };
const strFields = [
"customer_order_number",
"status",
"currency",
"language",
"scope_title",
"scope_description",
"notes",
];
for (const f of strFields) {
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
}
if (body.customer_id !== undefined)
data.customer_id = body.customer_id ? Number(body.customer_id) : null;
if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate);
if (body.apply_vat !== undefined)
data.apply_vat =
body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === "1";
if (Array.isArray(body.items) || Array.isArray(body.sections)) {
if (currentStatus !== "prijata" && currentStatus !== "v_realizaci") {
return {
error: "Nelze upravit položky dokončené/stornované objednávky",
status: 400,
} as const;
}
if (
body.status !== undefined &&
(String(body.status) === "dokoncena" ||
String(body.status) === "stornovana")
) {
return {
error: "Nelze upravit položky při změně stavu na dokončeno/storno",
status: 400,
} as const;
}
await prisma.$transaction(async (tx) => {
await tx.orders.update({ where: { id }, data });
// Sync project status when order status changes (matching PHP)
if (body.status !== undefined && String(body.status) !== currentStatus) {
const statusMap: Record<string, string> = {
v_realizaci: "aktivni",
dokoncena: "dokonceny",
stornovana: "zruseny",
};
const projectStatus = statusMap[String(body.status)];
if (projectStatus) {
await tx.projects.updateMany({
where: { order_id: id },
data: { status: projectStatus },
});
}
}
if (Array.isArray(body.items)) {
await tx.order_items.deleteMany({ where: { order_id: id } });
await tx.order_items.createMany({
data: (body.items as OrderItemInput[]).map((item, i) => ({
order_id: id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
is_included_in_total: item.is_included_in_total !== false,
position: item.position ?? i,
})),
});
}
if (Array.isArray(body.sections)) {
await tx.order_sections.deleteMany({ where: { order_id: id } });
await tx.order_sections.createMany({
data: (body.sections as OrderSectionInput[]).map((s, i) => ({
order_id: id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
});
} else {
await prisma.orders.update({ where: { id }, data });
// Sync project status when order status changes (matching PHP)
if (body.status !== undefined && String(body.status) !== currentStatus) {
const statusMap: Record<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 },
});
}
}
}
return { data: { id, order_number: existing.order_number } };
}
export async function deleteOrder(id: number) {
const existing = await prisma.orders.findUnique({ where: { id } });
if (!existing)
return { error: "Objednávka nenalezena", status: 404 } as const;
// Fetch linked projects before the transaction for number release later
const linkedProjects = await prisma.projects.findMany({
where: { order_id: id },
select: { id: true, created_at: true },
});
await prisma.$transaction(async (tx) => {
// Clear quotation back-reference (matching PHP)
await tx.quotations.updateMany({
where: { order_id: id },
data: { order_id: null },
});
// Delete linked project and its notes (matching PHP)
if (linkedProjects.length > 0) {
const projectIds = linkedProjects.map((p) => p.id);
await tx.project_notes.deleteMany({
where: { project_id: { in: projectIds } },
});
await tx.projects.deleteMany({ where: { order_id: id } });
}
// Explicitly clean up child rows
await tx.order_items.deleteMany({ where: { order_id: id } });
await tx.order_sections.deleteMany({ where: { order_id: id } });
await tx.orders.delete({ where: { id } });
});
const year = existing.created_at
? new Date(existing.created_at).getFullYear()
: new Date().getFullYear();
await releaseSharedNumber(year, existing.order_number ?? undefined);
return { data: { id, order_number: existing.order_number } };
}
export async function getNextOrderNumber() {
return previewSharedNumber();
}