diff --git a/src/__tests__/nas-document-manager.test.ts b/src/__tests__/nas-document-manager.test.ts new file mode 100644 index 0000000..02b1ba0 --- /dev/null +++ b/src/__tests__/nas-document-manager.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { NasDocumentManager } from "../services/nas-financials-manager"; + +/** + * Generic NAS archival manager backing both the invoices NAS (NAS_INVOICES) and + * the orders NAS (NAS_ORDERS). Uses an isolated temp dir via the constructor + * seam so it is deterministic and passes even where the real NAS (Z:) is not + * mounted. Layout: {base}/Vydané/YYYY/MM/.pdf — diacritics preserved. + */ +describe("NasDocumentManager issued-PDF round-trip", () => { + let tmpBase: string; + let nas: NasDocumentManager; + + beforeEach(() => { + tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "nas-doc-")); + nas = new NasDocumentManager(tmpBase); + }); + + afterEach(() => { + fs.rmSync(tmpBase, { recursive: true, force: true }); + }); + + it("saves an issued PDF under Vydané/YYYY/MM/.pdf and reads it back", () => { + const buf = Buffer.from("%PDF-1.4 fake order pdf bytes", "utf8"); + const rel = nas.saveIssuedPdf("25P0001", 2026, 6, buf); + + expect(rel).toBe("Vydané/2026/06/25P0001.pdf"); + const onDisk = path.join(tmpBase, "Vydané", "2026", "06", "25P0001.pdf"); + expect(fs.existsSync(onDisk)).toBe(true); + + const read = nas.readIssued(rel!); + expect(read).not.toBeNull(); + expect(read!.fileName).toBe("25P0001.pdf"); + expect(read!.data.equals(buf)).toBe(true); + }); + + it("cleanIssued removes a previously-saved PDF across year/month folders", () => { + const buf = Buffer.from("pdf", "utf8"); + const rel = nas.saveIssuedPdf("25P0002", 2026, 6, buf); + expect(nas.readIssued(rel!)).not.toBeNull(); + + nas.cleanIssued("25P0002"); + expect(nas.readIssued(rel!)).toBeNull(); + }); + + it("an unconfigured (empty basePath) manager is not configured and saves nothing", () => { + const blank = new NasDocumentManager(""); + expect(blank.isConfigured()).toBe(false); + expect( + blank.saveIssuedPdf("25P0003", 2026, 6, Buffer.from("x")), + ).toBeNull(); + }); +}); diff --git a/src/admin/pages/IssuedOrderDetail.tsx b/src/admin/pages/IssuedOrderDetail.tsx index 8e73ab6..d0878db 100644 --- a/src/admin/pages/IssuedOrderDetail.tsx +++ b/src/admin/pages/IssuedOrderDetail.tsx @@ -775,6 +775,18 @@ export default function IssuedOrderDetail() { alert.success( isEdit ? "Objednávka byla uložena" : "Objednávka byla vytvořena", ); + + // Archive the PDF to NAS for any non-draft order (create-as-sent, + // finalize draft→sent, or editing an already-live order). A draft has no + // PO number, so it is skipped (the backend also guards on po_number). + // Fire-and-forget: never block the success toast or navigation. + const recordId = isEdit ? id : data.id; + const effectiveStatus = targetStatus ?? form.status; + if (recordId && effectiveStatus !== "draft") { + apiFetch(`${API_BASE}/issued-orders-pdf/${recordId}?save=1`).catch( + () => {}, + ); + } // Reflect the just-saved status locally. On CREATE the component is reused // for the redirect to the new order's detail and the one-shot edit-mode // hydration won't re-run (dataReady stays true), so without this the page diff --git a/src/admin/pages/Settings.tsx b/src/admin/pages/Settings.tsx index 84191c5..8204403 100644 --- a/src/admin/pages/Settings.tsx +++ b/src/admin/pages/Settings.tsx @@ -758,7 +758,8 @@ export default function Settings() { {( [ ["Projekty", si.nas?.projects], - ["Finance", si.nas?.financials], + ["Faktury", si.nas?.invoices], + ["Objednávky", si.nas?.orders], ["Nabídky", si.nas?.offers], ] as [string, Record][] ).map(([label, info]) => ( diff --git a/src/config/env.ts b/src/config/env.ts index 5f28949..8f36a0c 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -67,7 +67,9 @@ export const config = { nas: { path: process.env.NAS_PATH || "Z:/02_PROJEKTY", - financialsPath: process.env.NAS_FINANCIALS_PATH || "", + invoicesPath: + process.env.NAS_INVOICES || process.env.NAS_FINANCIALS_PATH || "", + ordersPath: process.env.NAS_ORDERS || "", offersPath: process.env.NAS_OFFERS_PATH || "", maxUploadSize: (() => { const parsed = parseInt(process.env.MAX_UPLOAD_SIZE || "52428800", 10); diff --git a/src/routes/admin/company-settings.ts b/src/routes/admin/company-settings.ts index 9c0c3b8..0bbb506 100644 --- a/src/routes/admin/company-settings.ts +++ b/src/routes/admin/company-settings.ts @@ -15,7 +15,10 @@ import { invalidateSettingsCache } from "../../services/system-settings"; import os from "os"; import { config } from "../../config/env"; import { NasFileManager } from "../../services/nas-file-manager"; -import { nasFinancialsManager } from "../../services/nas-financials-manager"; +import { + nasInvoicesManager, + nasOrdersManager, +} from "../../services/nas-financials-manager"; import { nasOffersManager } from "../../services/nas-offers-manager"; /** Encode custom_fields + supplier_field_order into a single JSON blob (matching PHP format) */ @@ -420,8 +423,11 @@ export default async function companySettingsRoutes( projects: { configured: projectNas.isConfigured(), }, - financials: { - configured: nasFinancialsManager.isConfigured(), + invoices: { + configured: nasInvoicesManager.isConfigured(), + }, + orders: { + configured: nasOrdersManager.isConfigured(), }, offers: { configured: nasOffersManager.isConfigured(), diff --git a/src/routes/admin/invoices-pdf.ts b/src/routes/admin/invoices-pdf.ts index 1415068..8841f5e 100644 --- a/src/routes/admin/invoices-pdf.ts +++ b/src/routes/admin/invoices-pdf.ts @@ -3,7 +3,7 @@ import QRCode from "qrcode"; import prisma from "../../config/database"; import { requirePermission } from "../../middleware/auth"; import { localDateCzStr } from "../../utils/date"; -import { nasFinancialsManager } from "../../services/nas-financials-manager"; +import { nasInvoicesManager } from "../../services/nas-financials-manager"; import { htmlToPdf } from "../../utils/html-to-pdf"; import { getRate } from "../../services/exchange-rates"; import { localDateStr } from "../../utils/date"; @@ -1067,15 +1067,15 @@ ${indentCSS} // Save PDF to NAS if ( saveMode && - nasFinancialsManager.isConfigured() && + nasInvoicesManager.isConfigured() && invoice.invoice_number ) { const issueDate = invoice.issue_date ? new Date(invoice.issue_date) : new Date(); - nasFinancialsManager.cleanIssuedInvoice(invoice.invoice_number!); + nasInvoicesManager.cleanIssued(invoice.invoice_number!); const pdfBuffer = await htmlToPdf(html); - nasFinancialsManager.saveIssuedInvoicePdf( + nasInvoicesManager.saveIssuedPdf( invoice.invoice_number!, issueDate.getFullYear(), issueDate.getMonth() + 1, diff --git a/src/routes/admin/invoices.ts b/src/routes/admin/invoices.ts index cd02a5d..6813841 100644 --- a/src/routes/admin/invoices.ts +++ b/src/routes/admin/invoices.ts @@ -21,7 +21,7 @@ import { updateInvoice, deleteInvoice, } from "../../services/invoices.service"; -import { nasFinancialsManager } from "../../services/nas-financials-manager"; +import { nasInvoicesManager } from "../../services/nas-financials-manager"; export default async function invoicesRoutes( fastify: FastifyInstance, @@ -202,7 +202,7 @@ export default async function invoicesRoutes( // Delete PDF from NAS if (existing.invoice_number) { - await nasFinancialsManager.cleanIssuedInvoice(existing.invoice_number); + await nasInvoicesManager.cleanIssued(existing.invoice_number); } await logAudit({ @@ -233,7 +233,7 @@ export default async function invoicesRoutes( const d = new Date(invoice.issue_date); const relPath = `Vydané/${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/${invoice.invoice_number}.pdf`; - const file = nasFinancialsManager.readIssuedInvoice(relPath); + const file = nasInvoicesManager.readIssued(relPath); if (!file) return error(reply, "PDF soubor nenalezen", 404); const safeName = invoice.invoice_number.replace(/[\r\n"]/g, ""); diff --git a/src/routes/admin/issued-orders-pdf.ts b/src/routes/admin/issued-orders-pdf.ts index bf69654..88d1c50 100644 --- a/src/routes/admin/issued-orders-pdf.ts +++ b/src/routes/admin/issued-orders-pdf.ts @@ -1,9 +1,10 @@ import { FastifyInstance } from "fastify"; import prisma from "../../config/database"; import { requirePermission } from "../../middleware/auth"; -import { parseId } from "../../utils/response"; +import { parseId, success } from "../../utils/response"; import { localDateCzStr } from "../../utils/date"; import { htmlToPdf } from "../../utils/html-to-pdf"; +import { nasOrdersManager } from "../../services/nas-financials-manager"; import createDOMPurify from "dompurify"; import { JSDOM } from "jsdom"; @@ -856,6 +857,25 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) { settings, lang, ); + + // ?save=1 → archive the PDF to NAS instead of returning it (mirrors + // invoices-pdf). A draft has no po_number → the guard skips it. + const saveMode = query.save === "1"; + if (saveMode && nasOrdersManager.isConfigured() && order.po_number) { + const baseDate = order.order_date + ? new Date(order.order_date) + : new Date(); + nasOrdersManager.cleanIssued(order.po_number); + const pdfBuffer = await htmlToPdf(html); + nasOrdersManager.saveIssuedPdf( + order.po_number, + baseDate.getFullYear(), + baseDate.getMonth() + 1, + pdfBuffer, + ); + return success(reply, null, 200, "PDF uloženo"); + } + const pdf = await htmlToPdf(html); const filename = `Objednavka-${order.po_number || String(id)}.pdf`; return reply diff --git a/src/routes/admin/received-invoices.ts b/src/routes/admin/received-invoices.ts index dc2efaf..c344693 100644 --- a/src/routes/admin/received-invoices.ts +++ b/src/routes/admin/received-invoices.ts @@ -12,7 +12,7 @@ import { CreateReceivedInvoiceSchema, UpdateReceivedInvoiceSchema, } from "../../schemas/received-invoices.schema"; -import { nasFinancialsManager } from "../../services/nas-financials-manager"; +import { nasInvoicesManager } from "../../services/nas-financials-manager"; import { toCzk } from "../../services/exchange-rates"; import path from "path"; @@ -203,12 +203,12 @@ export default async function receivedInvoicesRoutes( }); if (!invoice?.file_name) return error(reply, "Soubor nenalezen", 404); - const relPath = nasFinancialsManager.buildReceivedPath( + const relPath = nasInvoicesManager.buildReceivedPath( invoice.file_name, invoice.year, invoice.month, ); - const nasFile = nasFinancialsManager.readReceivedInvoice(relPath); + const nasFile = nasInvoicesManager.readReceived(relPath); if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404); const mime = invoice.file_mime || "application/pdf"; @@ -295,7 +295,7 @@ export default async function receivedInvoicesRoutes( const now = new Date(); const createdIds: number[] = []; - const useNas = nasFinancialsManager.isConfigured(); + const useNas = nasInvoicesManager.isConfigured(); for (let i = 0; i < files.length; i++) { const file = files[i]; @@ -319,7 +319,7 @@ export default async function receivedInvoicesRoutes( return error(reply, "NAS úložiště není nakonfigurováno", 503); } - const nasResult = nasFinancialsManager.saveReceivedInvoice( + const nasResult = nasInvoicesManager.saveReceived( file.name, invoiceYear, invoiceMonth, @@ -584,12 +584,12 @@ export default async function receivedInvoicesRoutes( await prisma.received_invoices.delete({ where: { id } }); if (existing.file_name) { - const relPath = nasFinancialsManager.buildReceivedPath( + const relPath = nasInvoicesManager.buildReceivedPath( existing.file_name, existing.year, existing.month, ); - nasFinancialsManager.deleteReceivedInvoice(relPath); + nasInvoicesManager.deleteReceived(relPath); } await logAudit({ request, diff --git a/src/routes/admin/warehouse.ts b/src/routes/admin/warehouse.ts index 8bf85f5..f75d47f 100644 --- a/src/routes/admin/warehouse.ts +++ b/src/routes/admin/warehouse.ts @@ -7,7 +7,7 @@ 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 { nasFinancialsManager } from "../../services/nas-financials-manager"; +import { nasInvoicesManager } from "../../services/nas-financials-manager"; import { getItemAvailableQty, getItemTotalStock, @@ -1315,11 +1315,11 @@ export default async function warehouseRoutes( const year = now.getFullYear(); const month = now.getMonth() + 1; - if (!nasFinancialsManager.isConfigured()) { + if (!nasInvoicesManager.isConfigured()) { return error(reply, "Úložiště souborů není dostupné", 503); } - const result = nasFinancialsManager.saveReceivedInvoice( + const result = nasInvoicesManager.saveReceived( part.filename, year, month, @@ -1344,9 +1344,7 @@ export default async function warehouseRoutes( }, }); } catch (e) { - const removed = nasFinancialsManager.deleteReceivedInvoice( - result.filePath, - ); + const removed = nasInvoicesManager.deleteReceived(result.filePath); request.log.error( { err: e, filePath: result.filePath, orphanRemoved: removed }, "warehouse attachment DB insert failed after NAS write; rolled back NAS file", @@ -1392,7 +1390,7 @@ export default async function warehouseRoutes( // Delete file from NAS. A failure here is non-fatal (we still remove the // DB row) but must not be silent — log it so an undeleted NAS file is // visible rather than leaking quietly. - const nasDeleted = nasFinancialsManager.deleteReceivedInvoice( + const nasDeleted = nasInvoicesManager.deleteReceived( attachment.file_path, ); if (!nasDeleted) { diff --git a/src/services/nas-financials-manager.ts b/src/services/nas-financials-manager.ts index 1167426..915db86 100644 --- a/src/services/nas-financials-manager.ts +++ b/src/services/nas-financials-manager.ts @@ -3,7 +3,10 @@ import path from "path"; import { config } from "../config/env"; /** - * NAS storage for financial documents. + * NAS storage for financial/order documents (issued invoices, received + * invoices, issued purchase orders — any document archived under the standard + * Vydané/Přijaté layout). One instance is bound to a single base path, so a + * separate singleton is exported per document family (invoices vs orders). * Structure: {basePath}/Vydané/YYYY/MM/ and {basePath}/Přijaté/YYYY/MM/ * * PERFORMANCE NOTE (audit finding — DEFERRED, intentional): @@ -11,9 +14,9 @@ import { config } from "../config/env"; * readFileSync/readdirSync/statSync). When basePath is a network (SMB) share, a * slow or hung NAS will BLOCK the Node event loop for the duration of the call, * stalling all other requests in the same process. This is a known trade-off: - * the file sizes are small (invoice PDFs), the call sites are low-frequency - * admin operations, and the synchronous API lets callers treat a returned path - * as a verified, durable write (see migrate-received-invoices-to-nas.ts, which + * the file sizes are small (PDFs), the call sites are low-frequency admin + * operations, and the synchronous API lets callers treat a returned path as a + * verified, durable write (see migrate-received-invoices-to-nas.ts, which * relies on this to safely null out the DB BLOB). Converting to the async * fs.promises API is a large, cross-cutting change touching every caller and * their error handling — deliberately NOT done here to keep this fix scoped. @@ -22,12 +25,11 @@ import { config } from "../config/env"; const DIR_ISSUED = "Vydané"; const DIR_RECEIVED = "Přijaté"; -class NasFinancialsManager { +export class NasDocumentManager { private readonly basePath: string; - constructor() { - const raw = config.nas.financialsPath; - this.basePath = raw ? path.resolve(raw).replace(/\\/g, "/") : ""; + constructor(basePath: string) { + this.basePath = basePath ? path.resolve(basePath).replace(/\\/g, "/") : ""; } isConfigured(): boolean { @@ -48,12 +50,12 @@ class NasFinancialsManager { } } - // ── Created (issued) invoices ──────────────────────────────────── + // ── Created (issued) documents ─────────────────────────────────── - /** Remove any existing PDF for this invoice number across all year/month folders */ - cleanIssuedInvoice(invoiceNumber: string): void { + /** Remove any existing PDF for this document number across all year/month folders */ + cleanIssued(documentNumber: string): void { if (!this.basePath) return; - const safeName = this.sanitizeFilename(invoiceNumber) + ".pdf"; + const safeName = this.sanitizeFilename(documentNumber) + ".pdf"; const issuedDir = path.join(this.basePath, DIR_ISSUED); try { if (!fs.existsSync(issuedDir)) return; @@ -80,20 +82,20 @@ class NasFinancialsManager { } } catch (err) { console.error( - "[nas-financials-manager] cleanIssuedInvoice failed for:", - invoiceNumber, + "[nas-financials-manager] cleanIssued failed for:", + documentNumber, err, ); } } - saveIssuedInvoicePdf( - invoiceNumber: string, + saveIssuedPdf( + documentNumber: string, year: number, month: number, pdfBuffer: Buffer, ): string | null { - // Guard parity with saveReceivedInvoice: without this, an unconfigured + // Guard parity with saveReceived: without this, an unconfigured // (empty) basePath would let ensureDir build a RELATIVE path and write the // PDF into the process CWD instead of the NAS. Bail out instead. if (!this.isConfigured()) return null; @@ -101,7 +103,7 @@ class NasFinancialsManager { const dir = this.ensureDir(DIR_ISSUED, year, month); if (!dir) return null; - const safeName = this.sanitizeFilename(invoiceNumber) + ".pdf"; + const safeName = this.sanitizeFilename(documentNumber) + ".pdf"; const fullPath = this.uniquePath(dir, safeName); try { @@ -109,7 +111,7 @@ class NasFinancialsManager { return `${DIR_ISSUED}/${year}/${this.pad(month)}/${path.basename(fullPath)}`; } catch (err) { console.error( - "[nas-financials-manager] write failed for issued invoice:", + "[nas-financials-manager] write failed for issued document:", fullPath, err, ); @@ -117,9 +119,7 @@ class NasFinancialsManager { } } - readIssuedInvoice( - relativePath: string, - ): { data: Buffer; fileName: string } | null { + readIssued(relativePath: string): { data: Buffer; fileName: string } | null { const fullPath = this.resolveSafePath(relativePath); if (!fullPath) return null; try { @@ -127,7 +127,7 @@ class NasFinancialsManager { return { data, fileName: path.basename(fullPath) }; } catch (err) { console.error( - "[nas-financials-manager] read failed for issued invoice:", + "[nas-financials-manager] read failed for issued document:", fullPath, err, ); @@ -135,7 +135,7 @@ class NasFinancialsManager { } } - deleteIssuedInvoice(relativePath: string): boolean { + deleteIssued(relativePath: string): boolean { const fullPath = this.resolveSafePath(relativePath); if (!fullPath) return false; try { @@ -143,7 +143,7 @@ class NasFinancialsManager { return true; } catch (err) { console.error( - "[nas-financials-manager] delete failed for issued invoice:", + "[nas-financials-manager] delete failed for issued document:", fullPath, err, ); @@ -151,21 +151,21 @@ class NasFinancialsManager { } } - // ── Received invoices ──────────────────────────────────────────── + // ── Received documents ─────────────────────────────────────────── buildReceivedPath(fileName: string, year: number, month: number): string { const safeName = this.sanitizeFilename(fileName) || "file"; return `${DIR_RECEIVED}/${year}/${this.pad(month)}/${safeName}`; } - saveReceivedInvoice( + saveReceived( originalName: string, year: number, month: number, fileBuffer: Buffer, ): { filePath: string } | { error: string } { if (!this.isConfigured()) { - return { error: "NAS financials path is not configured" }; + return { error: "NAS path is not configured" }; } const dir = this.ensureDir(DIR_RECEIVED, year, month); @@ -188,9 +188,7 @@ class NasFinancialsManager { } } - readReceivedInvoice( - filePath: string, - ): { data: Buffer; fileName: string } | null { + readReceived(filePath: string): { data: Buffer; fileName: string } | null { const fullPath = this.resolveSafePath(filePath); if (!fullPath) return null; @@ -199,7 +197,7 @@ class NasFinancialsManager { return { data, fileName: path.basename(fullPath) }; } catch (err) { console.error( - "[nas-financials-manager] read failed for received invoice:", + "[nas-financials-manager] read failed for received document:", fullPath, err, ); @@ -207,7 +205,7 @@ class NasFinancialsManager { } } - deleteReceivedInvoice(filePath: string): boolean { + deleteReceived(filePath: string): boolean { const fullPath = this.resolveSafePath(filePath); if (!fullPath) return false; @@ -216,7 +214,7 @@ class NasFinancialsManager { return true; } catch (err) { console.error( - "[nas-financials-manager] delete failed for received invoice:", + "[nas-financials-manager] delete failed for received document:", fullPath, err, ); @@ -303,4 +301,7 @@ class NasFinancialsManager { } } -export const nasFinancialsManager = new NasFinancialsManager(); +export const nasInvoicesManager = new NasDocumentManager( + config.nas.invoicesPath, +); +export const nasOrdersManager = new NasDocumentManager(config.nas.ordersPath);