import { FastifyInstance } from "fastify"; import prisma from "../../config/database"; import { requirePermission, requireAnyPermission } from "../../middleware/auth"; import { logAudit } from "../../services/audit"; import { success, error, parseId, paginated } from "../../utils/response"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parseBody } from "../../schemas/common"; import { CreateIssuedOrderSchema, UpdateIssuedOrderSchema, } from "../../schemas/issued-orders.schema"; import { listIssuedOrders, getIssuedOrderTotals, getIssuedOrder, createIssuedOrder, updateIssuedOrder, deleteIssuedOrder, getNextIssuedOrderNumberPreview, } from "../../services/issued-orders.service"; import { renderIssuedOrderHtml, buildIssuedOrderPdfFooter, buildIssuedOrderPdfHeader, } from "./issued-orders-pdf"; import { htmlToPdf } from "../../utils/html-to-pdf"; import { logoDataUriFromSettings } from "../../utils/pdf-shared"; import { nasOrdersManager } from "../../services/nas-financials-manager"; import { contentDisposition } from "../../utils/content-disposition"; // 3× the client's 10s heartbeat: a single missed beat (background-tab // throttling, transient network) must not silently hand the lock to a second // editor. Keep in sync with quotations.ts and useDocumentLock. const LOCK_TIMEOUT_MS = 30 * 1000; export default async function issuedOrdersRoutes(fastify: FastifyInstance) { // GET /api/admin/issued-orders fastify.get( "/", { preHandler: requirePermission("orders.view") }, async (request, reply) => { const query = request.query as Record; const { page, limit, skip, sort, order, search } = parsePagination(query); const result = await listIssuedOrders({ page, limit, skip, sort, order, search, status: query.status ? String(query.status) : undefined, supplier_id: query.supplier_id ? Number(query.supplier_id) : undefined, month: query.month ? Number(query.month) : undefined, year: query.year ? Number(query.year) : undefined, }); return paginated( reply, result.data, buildPaginationMeta(result.total, page, limit), ); }, ); // GET /api/admin/issued-orders/next-number fastify.get( "/next-number", { preHandler: requirePermission("orders.create") }, async (_request, reply) => { return success(reply, await getNextIssuedOrderNumberPreview()); }, ); // GET /api/admin/issued-orders/stats — per-currency NET total summed over // the WHOLE filtered set (not one page). Issued orders are not tax documents // — no VAT anywhere. Registered BEFORE "/:id" so the literal "stats" path // isn't captured as an order id. fastify.get( "/stats", { preHandler: requirePermission("orders.view") }, async (request, reply) => { const query = request.query as Record; const result = await getIssuedOrderTotals({ search: query.search ? String(query.search) : undefined, status: query.status ? String(query.status) : undefined, supplier_id: query.supplier_id ? Number(query.supplier_id) : undefined, month: query.month ? Number(query.month) : undefined, year: query.year ? Number(query.year) : undefined, }); return success(reply, { totals: result.totals }); }, ); // GET /api/admin/issued-orders/suppliers — lightweight supplier lookup for // the PO supplier picker. Guarded by the ORDERS permissions (any of // view/create/edit), NOT warehouse.manage: the warehouse suppliers CRUD is // gated by warehouse.manage, which orders users typically lack — without // this endpoint they couldn't populate the picker. Registered BEFORE "/:id" // so the literal "suppliers" path isn't captured as an order id. fastify.get( "/suppliers", { preHandler: requireAnyPermission( "orders.view", "orders.create", "orders.edit", ), }, async (_request, reply) => { const suppliers = await prisma.sklad_suppliers.findMany({ where: { is_active: true }, select: { id: true, name: true, ico: true, dic: true, street: true, city: true, postal_code: true, country: true, }, // id tiebreak so same-name suppliers sort deterministically. orderBy: [{ name: "asc" }, { id: "asc" }], }); return success(reply, suppliers); }, ); // GET /api/admin/issued-orders/:id 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 getIssuedOrder(id); if (!order) return error(reply, "Objednávka nenalezena", 404); // `getIssuedOrder` already loaded locked_by/locked_at — enrich locked_by // to {user_id, username, full_name} ONLY when the lock is fresh and held // by ANOTHER user (same shape as offers so the frontend lock hook can be // shared); otherwise null. let lockedBy: { user_id: number; username: string; full_name: string; } | null = null; if (order.locked_by && order.locked_at) { const lockAge = Date.now() - new Date(order.locked_at).getTime(); if ( lockAge < LOCK_TIMEOUT_MS && order.locked_by !== request.authData!.userId ) { const lockUser = await prisma.users.findUnique({ where: { id: order.locked_by }, select: { id: true, username: true, first_name: true, last_name: true, }, }); if (lockUser) { lockedBy = { user_id: lockUser.id, username: lockUser.username, full_name: `${lockUser.first_name} ${lockUser.last_name}`.trim(), }; } } } return success(reply, { ...order, locked_by: lockedBy }); }, ); // POST /api/admin/issued-orders/:id/lock — acquire edit lock (mirrors offers) fastify.post<{ Params: { id: string } }>( "/:id/lock", { preHandler: requirePermission("orders.edit") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const order = await prisma.issued_orders.findUnique({ where: { id }, select: { locked_by: true, locked_at: true }, }); if (!order) return error(reply, "Objednávka nenalezena", 404); // Check if locked by someone else and lock is fresh if (order.locked_by && order.locked_at) { const lockAge = Date.now() - new Date(order.locked_at).getTime(); if ( lockAge < LOCK_TIMEOUT_MS && order.locked_by !== request.authData!.userId ) { const lockUser = await prisma.users.findUnique({ where: { id: order.locked_by }, select: { first_name: true, last_name: true }, }); return error( reply, `Objednávku právě upravuje ${lockUser ? `${lockUser.first_name} ${lockUser.last_name}`.trim() : "jiný uživatel"}`, 423, ); } } await prisma.issued_orders.update({ where: { id }, data: { locked_by: request.authData!.userId, locked_at: new Date() }, }); return success(reply, null, 200, "Zámek nastaven"); }, ); // POST /api/admin/issued-orders/:id/heartbeat — keep lock alive fastify.post<{ Params: { id: string } }>( "/:id/heartbeat", { preHandler: requirePermission("orders.edit") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; await prisma.issued_orders.updateMany({ where: { id, locked_by: request.authData!.userId }, data: { locked_at: new Date() }, }); return success(reply, null); }, ); // POST /api/admin/issued-orders/:id/unlock — release lock fastify.post<{ Params: { id: string } }>( "/:id/unlock", { preHandler: requirePermission("orders.edit") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; await prisma.issued_orders.updateMany({ where: { id, locked_by: request.authData!.userId }, data: { locked_by: null, locked_at: null }, }); return success(reply, null); }, ); // GET /api/admin/issued-orders/:id/file — serve the archived PDF from the // NAS; on an archive miss, fall back to a LIVE render from current data, // re-archive the fresh PDF, and serve it (archived-file model with live // fallback — same model as offers' /:id/file). fastify.get<{ Params: { id: string } }>( "/:id/file", { preHandler: requirePermission("orders.view") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const order = await prisma.issued_orders.findUnique({ where: { id } }); // A draft has no po_number → nothing was ever archived; 404 like offers. if (!order?.po_number) return error(reply, "Objednávka nenalezena", 404); const fileName = `Objednavka-${order.po_number}.pdf`; // Archive hit: the by-number sweep finds the PDF in whichever // Vydané/YYYY/MM folder it was saved to (order_date can move after // archiving, so a fixed expected path would miss). const archived = nasOrdersManager.readIssuedByNumber(order.po_number); if (archived) { return reply .type("application/pdf") .header("Content-Disposition", contentDisposition("inline", fileName)) .send(archived.data); } // Archive miss → live render (same data path as issued-orders-pdf), // then archive the fresh PDF so the next request is a plain hit. try { const items = await prisma.issued_order_items.findMany({ where: { issued_order_id: id }, orderBy: { position: "asc" }, }); const sections = await prisma.issued_order_sections.findMany({ where: { issued_order_id: id }, orderBy: [{ position: "asc" }, { id: "asc" }], }); const supplier = order.supplier_id ? ((await prisma.sklad_suppliers.findUnique({ where: { id: order.supplier_id }, })) as Record | null) : null; const settings = (await prisma.company_settings.findFirst()) as Record< string, unknown > | null; const lang = order.language === "en" ? ("en" as const) : ("cs" as const); const issuer = { name: `${request.authData?.firstName ?? ""} ${request.authData?.lastName ?? ""}`.trim(), }; const html = renderIssuedOrderHtml( order, items, supplier, settings, lang, issuer, sections, ); const pdf = await htmlToPdf(html, { headerTemplate: buildIssuedOrderPdfHeader( lang, logoDataUriFromSettings(settings), order.po_number || "", ), footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name), }); if (nasOrdersManager.isConfigured()) { const baseDate = order.order_date ? new Date(order.order_date) : new Date(); nasOrdersManager.cleanIssued(order.po_number); const saved = nasOrdersManager.saveIssuedPdf( order.po_number, baseDate.getFullYear(), baseDate.getMonth() + 1, pdf, ); if (!saved) { request.log.error( `Archivace PDF objednávky ${order.po_number} na NAS při /file fallbacku selhala`, ); } } return reply .type("application/pdf") .header("Content-Disposition", contentDisposition("inline", fileName)) .send(pdf); } catch (err) { request.log.error( err, "Issued order /file live-render fallback failed", ); return error(reply, "Chyba při generování PDF", 500); } }, ); // POST /api/admin/issued-orders fastify.post( "/", { preHandler: requirePermission("orders.create") }, async (request, reply) => { const parsed = parseBody(CreateIssuedOrderSchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400); const order = await createIssuedOrder(parsed.data); if ("error" in order) { if (order.error === "supplier_not_found") return error(reply, "Dodavatel nenalezen", 400); if (order.error === "po_number_taken") return error(reply, "Číslo objednávky je již použito", 409); return error(reply, "Neznámá chyba", 500); } await logAudit({ request, authData: request.authData, action: "create", entityType: "issued_order", entityId: order.id, description: `Vytvořena vydaná objednávka ${order.po_number ?? "koncept"}`, }); return success( reply, { id: order.id, po_number: order.po_number }, 201, "Objednávka byla vytvořena", ); }, ); // PUT /api/admin/issued-orders/:id fastify.put<{ Params: { id: string } }>( "/:id", { preHandler: requirePermission("orders.edit") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const parsed = parseBody(UpdateIssuedOrderSchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400); const result = await updateIssuedOrder(id, parsed.data); if ("error" in result) { if (result.error === "not_found") return error(reply, "Objednávka nenalezena", 404); if (result.error === "not_editable") return error(reply, "Objednávku v tomto stavu nelze upravovat", 400); if (result.error === "supplier_not_found") return error(reply, "Dodavatel nenalezen", 400); if (result.error === "invalid_transition") return error( reply, `Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`, 400, ); return error(reply, "Neznámá chyba", 500); } await logAudit({ request, authData: request.authData, action: "update", entityType: "issued_order", entityId: id, description: `Upravena vydaná objednávka ${result.po_number ?? "koncept"}`, oldValues: result.oldValues, newValues: result.newValues, }); // po_number in the response so a draft->sent finalize immediately shows // the assigned number (POST already returns it). return success( reply, { id, po_number: result.po_number }, 200, "Objednávka byla aktualizována", ); }, ); // DELETE /api/admin/issued-orders/:id 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 deleteIssuedOrder(id); if (!existing) return error(reply, "Objednávka nenalezena", 404); await logAudit({ request, authData: request.authData, action: "delete", entityType: "issued_order", entityId: id, description: `Smazána vydaná objednávka ${existing.po_number ?? "koncept"}`, oldValues: existing as unknown as Record, }); return success(reply, null, 200, "Objednávka smazána"); }, ); }