import type { $Enums } from "@prisma/client"; import prisma from "../config/database"; import { generateIssuedOrderNumber, previewIssuedOrderNumber, releaseIssuedOrderNumber, } from "./numbering.service"; export interface IssuedOrderItemInput { description?: string | null; item_description?: string | null; quantity?: number | string | null; unit?: string | null; unit_price?: number | string | null; vat_rate?: number | string | null; position?: number | null; } export interface IssuedOrderInput { po_number?: string | number | null; customer_id?: number | string | null; status?: string; currency?: string; vat_rate?: number | string | null; apply_vat?: boolean | number | string; exchange_rate?: number | string | null; order_date?: string | null; delivery_date?: string | null; language?: string; delivery_terms?: string | null; payment_terms?: string | null; issued_by?: string | null; notes?: string | null; internal_notes?: string | null; items?: IssuedOrderItemInput[]; [key: string]: unknown; } interface ListIssuedOrdersParams { page: number; limit: number; skip: number; sort: string; order: string; search?: string; status?: string; customer_id?: number; month?: number; year?: number; } const VALID_TRANSITIONS: Record = { draft: ["sent", "cancelled"], sent: ["confirmed", "cancelled"], confirmed: ["completed", "cancelled"], completed: [], cancelled: [], }; const ALLOWED_SORT_FIELDS = [ "id", "po_number", "status", "order_date", "currency", ]; /** NET base + VAT-on-top, rounded per line before accumulation (mirrors invoices). */ export function computeIssuedOrderTotals( items: Array<{ quantity: unknown; unit_price: unknown; vat_rate: unknown }>, applyVat: boolean | null, defaultVatRate: unknown, ) { let subtotal = 0; let vat = 0; for (const it of items) { const base = (Number(it.quantity) || 0) * (Number(it.unit_price) || 0); subtotal += base; if (applyVat) { const rate = it.vat_rate != null && it.vat_rate !== "" ? Number(it.vat_rate) : defaultVatRate != null ? Number(defaultVatRate) : 21; vat += Math.round(base * (rate / 100) * 100) / 100; } } return { subtotal: Math.round(subtotal * 100) / 100, vat_amount: Math.round(vat * 100) / 100, total: Math.round((subtotal + vat) * 100) / 100, }; } export async function listIssuedOrders(params: ListIssuedOrdersParams) { const { page, limit, skip, sort, order, search, status, customer_id, month, year, } = params; const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id"; const where: Record = {}; if (status) where.status = status; if (customer_id) where.customer_id = customer_id; if (search) { where.OR = [ { po_number: { contains: search } }, { customers: { name: { contains: search } } }, { customers: { company_id: { contains: search } } }, ]; } if (month && year) { const from = new Date(year, month - 1, 1); const to = new Date(year, month, 1); where.order_date = { gte: from, lt: to }; } // Normalize the direction so an unexpected value can't reach Prisma at runtime. const dir: "asc" | "desc" = order === "asc" ? "asc" : "desc"; // Tiebreak on id so same-day order_date rows sort deterministically. const orderBy: Record[] = [ { [sortField]: dir }, { id: dir }, ]; const [rows, total] = await Promise.all([ prisma.issued_orders.findMany({ where, skip, take: limit, orderBy, include: { customers: { select: { id: true, name: true } }, issued_order_items: true, }, }), prisma.issued_orders.count({ where }), ]); const enriched = rows.map((o) => { const totals = computeIssuedOrderTotals( o.issued_order_items, o.apply_vat, o.vat_rate, ); const { issued_order_items, ...rest } = o; return { ...rest, items: issued_order_items, customer_name: o.customers?.name || null, ...totals, }; }); return { data: enriched, total, page, limit }; } export async function getIssuedOrder(id: number) { const order = await prisma.issued_orders.findUnique({ where: { id }, include: { customers: true, issued_order_items: { orderBy: { position: "asc" } }, }, }); if (!order) return null; const { issued_order_items, ...rest } = order; return { ...rest, items: issued_order_items, customer: order.customers, customer_name: order.customers?.name || null, valid_transitions: VALID_TRANSITIONS[order.status as string] || [], }; } export async function createIssuedOrder(body: IssuedOrderInput) { return prisma.$transaction(async (tx) => { const poNumber = body.po_number !== undefined && body.po_number !== null && body.po_number !== "" ? String(body.po_number) : (await generateIssuedOrderNumber(undefined, tx)).number; const order = await tx.issued_orders.create({ data: { po_number: poNumber, customer_id: body.customer_id ? Number(body.customer_id) : null, status: (body.status ? String(body.status) : "draft") as $Enums.issued_orders_status, currency: body.currency ? String(body.currency) : "CZK", vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0, apply_vat: body.apply_vat !== false, exchange_rate: body.exchange_rate != null ? Number(body.exchange_rate) : 1.0, order_date: body.order_date ? new Date(String(body.order_date)) : new Date(), delivery_date: body.delivery_date ? new Date(String(body.delivery_date)) : null, language: body.language ? String(body.language) : "cs", delivery_terms: body.delivery_terms ? String(body.delivery_terms) : null, payment_terms: body.payment_terms ? String(body.payment_terms) : null, issued_by: body.issued_by ? String(body.issued_by) : null, notes: body.notes ? String(body.notes) : null, internal_notes: body.internal_notes ? String(body.internal_notes) : null, }, }); if (Array.isArray(body.items)) { await tx.issued_order_items.createMany({ data: body.items.map((item, i) => ({ issued_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, vat_rate: item.vat_rate ?? 21.0, position: item.position ?? i, })), }); } return order; }); } export async function updateIssuedOrder(id: number, body: IssuedOrderInput) { const existing = await prisma.issued_orders.findUnique({ where: { id } }); if (!existing) return { error: "not_found" as const }; const currentStatus = existing.status as string; if (body.status !== undefined && body.status !== currentStatus) { const newStatus = String(body.status); const allowed = VALID_TRANSITIONS[currentStatus] || []; if (!allowed.includes(newStatus)) { return { error: "invalid_transition" as const, currentStatus, newStatus }; } } const editable = currentStatus === "draft" || currentStatus === "sent"; const data: Record = { modified_at: new Date() }; if (editable) { const strFields = [ "currency", "language", "delivery_terms", "payment_terms", "issued_by", ]; 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 (body.exchange_rate !== undefined) data.exchange_rate = body.exchange_rate != null ? Number(body.exchange_rate) : null; if (body.order_date !== undefined) data.order_date = body.order_date ? new Date(String(body.order_date)) : null; if (body.delivery_date !== undefined) data.delivery_date = body.delivery_date ? new Date(String(body.delivery_date)) : null; if (body.notes !== undefined) data.notes = body.notes ? String(body.notes) : null; if (body.internal_notes !== undefined) data.internal_notes = body.internal_notes ? String(body.internal_notes) : null; } if (body.status !== undefined) data.status = String(body.status); await prisma.issued_orders.update({ where: { id }, data }); if (editable && Array.isArray(body.items)) { await prisma.$transaction(async (tx) => { await tx.issued_order_items.deleteMany({ where: { issued_order_id: id }, }); await tx.issued_order_items.createMany({ data: body.items!.map((item, i) => ({ issued_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, vat_rate: item.vat_rate ?? 21.0, position: item.position ?? i, })), }); }); } return { id, po_number: existing.po_number }; } export async function deleteIssuedOrder(id: number) { const existing = await prisma.issued_orders.findUnique({ where: { id } }); if (!existing) return null; await prisma.issued_orders.delete({ where: { id } }); // Reclaim the number against the year it was allocated in (the creation year), // not the current year — a cross-year delete must release the right sequence // or the number is never freed. const year = existing.created_at ? new Date(existing.created_at).getFullYear() : new Date().getFullYear(); await releaseIssuedOrderNumber(year, existing.po_number ?? undefined); return existing; } export async function getNextIssuedOrderNumberPreview() { return previewIssuedOrderNumber(); }