- 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>
556 lines
17 KiB
TypeScript
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();
|
|
}
|