feat(nas): archive issued-order PDFs to NAS_ORDERS; split NAS_FINANCIALS_PATH into NAS_INVOICES + NAS_ORDERS
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
56
src/__tests__/nas-document-manager.test.ts
Normal file
56
src/__tests__/nas-document-manager.test.ts
Normal file
@@ -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/<n>.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/<number>.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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -775,6 +775,18 @@ export default function IssuedOrderDetail() {
|
|||||||
alert.success(
|
alert.success(
|
||||||
isEdit ? "Objednávka byla uložena" : "Objednávka byla vytvořena",
|
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
|
// 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
|
// 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
|
// hydration won't re-run (dataReady stays true), so without this the page
|
||||||
|
|||||||
@@ -758,7 +758,8 @@ export default function Settings() {
|
|||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
["Projekty", si.nas?.projects],
|
["Projekty", si.nas?.projects],
|
||||||
["Finance", si.nas?.financials],
|
["Faktury", si.nas?.invoices],
|
||||||
|
["Objednávky", si.nas?.orders],
|
||||||
["Nabídky", si.nas?.offers],
|
["Nabídky", si.nas?.offers],
|
||||||
] as [string, Record<string, any>][]
|
] as [string, Record<string, any>][]
|
||||||
).map(([label, info]) => (
|
).map(([label, info]) => (
|
||||||
|
|||||||
@@ -67,7 +67,9 @@ export const config = {
|
|||||||
|
|
||||||
nas: {
|
nas: {
|
||||||
path: process.env.NAS_PATH || "Z:/02_PROJEKTY",
|
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 || "",
|
offersPath: process.env.NAS_OFFERS_PATH || "",
|
||||||
maxUploadSize: (() => {
|
maxUploadSize: (() => {
|
||||||
const parsed = parseInt(process.env.MAX_UPLOAD_SIZE || "52428800", 10);
|
const parsed = parseInt(process.env.MAX_UPLOAD_SIZE || "52428800", 10);
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import { invalidateSettingsCache } from "../../services/system-settings";
|
|||||||
import os from "os";
|
import os from "os";
|
||||||
import { config } from "../../config/env";
|
import { config } from "../../config/env";
|
||||||
import { NasFileManager } from "../../services/nas-file-manager";
|
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";
|
import { nasOffersManager } from "../../services/nas-offers-manager";
|
||||||
|
|
||||||
/** Encode custom_fields + supplier_field_order into a single JSON blob (matching PHP format) */
|
/** Encode custom_fields + supplier_field_order into a single JSON blob (matching PHP format) */
|
||||||
@@ -420,8 +423,11 @@ export default async function companySettingsRoutes(
|
|||||||
projects: {
|
projects: {
|
||||||
configured: projectNas.isConfigured(),
|
configured: projectNas.isConfigured(),
|
||||||
},
|
},
|
||||||
financials: {
|
invoices: {
|
||||||
configured: nasFinancialsManager.isConfigured(),
|
configured: nasInvoicesManager.isConfigured(),
|
||||||
|
},
|
||||||
|
orders: {
|
||||||
|
configured: nasOrdersManager.isConfigured(),
|
||||||
},
|
},
|
||||||
offers: {
|
offers: {
|
||||||
configured: nasOffersManager.isConfigured(),
|
configured: nasOffersManager.isConfigured(),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import QRCode from "qrcode";
|
|||||||
import prisma from "../../config/database";
|
import prisma from "../../config/database";
|
||||||
import { requirePermission } from "../../middleware/auth";
|
import { requirePermission } from "../../middleware/auth";
|
||||||
import { localDateCzStr } from "../../utils/date";
|
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 { htmlToPdf } from "../../utils/html-to-pdf";
|
||||||
import { getRate } from "../../services/exchange-rates";
|
import { getRate } from "../../services/exchange-rates";
|
||||||
import { localDateStr } from "../../utils/date";
|
import { localDateStr } from "../../utils/date";
|
||||||
@@ -1067,15 +1067,15 @@ ${indentCSS}
|
|||||||
// Save PDF to NAS
|
// Save PDF to NAS
|
||||||
if (
|
if (
|
||||||
saveMode &&
|
saveMode &&
|
||||||
nasFinancialsManager.isConfigured() &&
|
nasInvoicesManager.isConfigured() &&
|
||||||
invoice.invoice_number
|
invoice.invoice_number
|
||||||
) {
|
) {
|
||||||
const issueDate = invoice.issue_date
|
const issueDate = invoice.issue_date
|
||||||
? new Date(invoice.issue_date)
|
? new Date(invoice.issue_date)
|
||||||
: new Date();
|
: new Date();
|
||||||
nasFinancialsManager.cleanIssuedInvoice(invoice.invoice_number!);
|
nasInvoicesManager.cleanIssued(invoice.invoice_number!);
|
||||||
const pdfBuffer = await htmlToPdf(html);
|
const pdfBuffer = await htmlToPdf(html);
|
||||||
nasFinancialsManager.saveIssuedInvoicePdf(
|
nasInvoicesManager.saveIssuedPdf(
|
||||||
invoice.invoice_number!,
|
invoice.invoice_number!,
|
||||||
issueDate.getFullYear(),
|
issueDate.getFullYear(),
|
||||||
issueDate.getMonth() + 1,
|
issueDate.getMonth() + 1,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
updateInvoice,
|
updateInvoice,
|
||||||
deleteInvoice,
|
deleteInvoice,
|
||||||
} from "../../services/invoices.service";
|
} from "../../services/invoices.service";
|
||||||
import { nasFinancialsManager } from "../../services/nas-financials-manager";
|
import { nasInvoicesManager } from "../../services/nas-financials-manager";
|
||||||
|
|
||||||
export default async function invoicesRoutes(
|
export default async function invoicesRoutes(
|
||||||
fastify: FastifyInstance,
|
fastify: FastifyInstance,
|
||||||
@@ -202,7 +202,7 @@ export default async function invoicesRoutes(
|
|||||||
|
|
||||||
// Delete PDF from NAS
|
// Delete PDF from NAS
|
||||||
if (existing.invoice_number) {
|
if (existing.invoice_number) {
|
||||||
await nasFinancialsManager.cleanIssuedInvoice(existing.invoice_number);
|
await nasInvoicesManager.cleanIssued(existing.invoice_number);
|
||||||
}
|
}
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
@@ -233,7 +233,7 @@ export default async function invoicesRoutes(
|
|||||||
|
|
||||||
const d = new Date(invoice.issue_date);
|
const d = new Date(invoice.issue_date);
|
||||||
const relPath = `Vydané/${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/${invoice.invoice_number}.pdf`;
|
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);
|
if (!file) return error(reply, "PDF soubor nenalezen", 404);
|
||||||
|
|
||||||
const safeName = invoice.invoice_number.replace(/[\r\n"]/g, "");
|
const safeName = invoice.invoice_number.replace(/[\r\n"]/g, "");
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import prisma from "../../config/database";
|
import prisma from "../../config/database";
|
||||||
import { requirePermission } from "../../middleware/auth";
|
import { requirePermission } from "../../middleware/auth";
|
||||||
import { parseId } from "../../utils/response";
|
import { parseId, success } from "../../utils/response";
|
||||||
import { localDateCzStr } from "../../utils/date";
|
import { localDateCzStr } from "../../utils/date";
|
||||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||||
|
import { nasOrdersManager } from "../../services/nas-financials-manager";
|
||||||
import createDOMPurify from "dompurify";
|
import createDOMPurify from "dompurify";
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
|
|
||||||
@@ -856,6 +857,25 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
|||||||
settings,
|
settings,
|
||||||
lang,
|
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 pdf = await htmlToPdf(html);
|
||||||
const filename = `Objednavka-${order.po_number || String(id)}.pdf`;
|
const filename = `Objednavka-${order.po_number || String(id)}.pdf`;
|
||||||
return reply
|
return reply
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
CreateReceivedInvoiceSchema,
|
CreateReceivedInvoiceSchema,
|
||||||
UpdateReceivedInvoiceSchema,
|
UpdateReceivedInvoiceSchema,
|
||||||
} from "../../schemas/received-invoices.schema";
|
} 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 { toCzk } from "../../services/exchange-rates";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
@@ -203,12 +203,12 @@ export default async function receivedInvoicesRoutes(
|
|||||||
});
|
});
|
||||||
if (!invoice?.file_name) return error(reply, "Soubor nenalezen", 404);
|
if (!invoice?.file_name) return error(reply, "Soubor nenalezen", 404);
|
||||||
|
|
||||||
const relPath = nasFinancialsManager.buildReceivedPath(
|
const relPath = nasInvoicesManager.buildReceivedPath(
|
||||||
invoice.file_name,
|
invoice.file_name,
|
||||||
invoice.year,
|
invoice.year,
|
||||||
invoice.month,
|
invoice.month,
|
||||||
);
|
);
|
||||||
const nasFile = nasFinancialsManager.readReceivedInvoice(relPath);
|
const nasFile = nasInvoicesManager.readReceived(relPath);
|
||||||
if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
|
if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
|
||||||
|
|
||||||
const mime = invoice.file_mime || "application/pdf";
|
const mime = invoice.file_mime || "application/pdf";
|
||||||
@@ -295,7 +295,7 @@ export default async function receivedInvoicesRoutes(
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const createdIds: number[] = [];
|
const createdIds: number[] = [];
|
||||||
|
|
||||||
const useNas = nasFinancialsManager.isConfigured();
|
const useNas = nasInvoicesManager.isConfigured();
|
||||||
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
const file = files[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);
|
return error(reply, "NAS úložiště není nakonfigurováno", 503);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nasResult = nasFinancialsManager.saveReceivedInvoice(
|
const nasResult = nasInvoicesManager.saveReceived(
|
||||||
file.name,
|
file.name,
|
||||||
invoiceYear,
|
invoiceYear,
|
||||||
invoiceMonth,
|
invoiceMonth,
|
||||||
@@ -584,12 +584,12 @@ export default async function receivedInvoicesRoutes(
|
|||||||
await prisma.received_invoices.delete({ where: { id } });
|
await prisma.received_invoices.delete({ where: { id } });
|
||||||
|
|
||||||
if (existing.file_name) {
|
if (existing.file_name) {
|
||||||
const relPath = nasFinancialsManager.buildReceivedPath(
|
const relPath = nasInvoicesManager.buildReceivedPath(
|
||||||
existing.file_name,
|
existing.file_name,
|
||||||
existing.year,
|
existing.year,
|
||||||
existing.month,
|
existing.month,
|
||||||
);
|
);
|
||||||
nasFinancialsManager.deleteReceivedInvoice(relPath);
|
nasInvoicesManager.deleteReceived(relPath);
|
||||||
}
|
}
|
||||||
await logAudit({
|
await logAudit({
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { logAudit } from "../../services/audit";
|
|||||||
import { success, error, parseId, paginated } from "../../utils/response";
|
import { success, error, parseId, paginated } from "../../utils/response";
|
||||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||||
import { parseBody } from "../../schemas/common";
|
import { parseBody } from "../../schemas/common";
|
||||||
import { nasFinancialsManager } from "../../services/nas-financials-manager";
|
import { nasInvoicesManager } from "../../services/nas-financials-manager";
|
||||||
import {
|
import {
|
||||||
getItemAvailableQty,
|
getItemAvailableQty,
|
||||||
getItemTotalStock,
|
getItemTotalStock,
|
||||||
@@ -1315,11 +1315,11 @@ export default async function warehouseRoutes(
|
|||||||
const year = now.getFullYear();
|
const year = now.getFullYear();
|
||||||
const month = now.getMonth() + 1;
|
const month = now.getMonth() + 1;
|
||||||
|
|
||||||
if (!nasFinancialsManager.isConfigured()) {
|
if (!nasInvoicesManager.isConfigured()) {
|
||||||
return error(reply, "Úložiště souborů není dostupné", 503);
|
return error(reply, "Úložiště souborů není dostupné", 503);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = nasFinancialsManager.saveReceivedInvoice(
|
const result = nasInvoicesManager.saveReceived(
|
||||||
part.filename,
|
part.filename,
|
||||||
year,
|
year,
|
||||||
month,
|
month,
|
||||||
@@ -1344,9 +1344,7 @@ export default async function warehouseRoutes(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const removed = nasFinancialsManager.deleteReceivedInvoice(
|
const removed = nasInvoicesManager.deleteReceived(result.filePath);
|
||||||
result.filePath,
|
|
||||||
);
|
|
||||||
request.log.error(
|
request.log.error(
|
||||||
{ err: e, filePath: result.filePath, orphanRemoved: removed },
|
{ err: e, filePath: result.filePath, orphanRemoved: removed },
|
||||||
"warehouse attachment DB insert failed after NAS write; rolled back NAS file",
|
"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
|
// 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
|
// DB row) but must not be silent — log it so an undeleted NAS file is
|
||||||
// visible rather than leaking quietly.
|
// visible rather than leaking quietly.
|
||||||
const nasDeleted = nasFinancialsManager.deleteReceivedInvoice(
|
const nasDeleted = nasInvoicesManager.deleteReceived(
|
||||||
attachment.file_path,
|
attachment.file_path,
|
||||||
);
|
);
|
||||||
if (!nasDeleted) {
|
if (!nasDeleted) {
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import path from "path";
|
|||||||
import { config } from "../config/env";
|
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/
|
* Structure: {basePath}/Vydané/YYYY/MM/ and {basePath}/Přijaté/YYYY/MM/
|
||||||
*
|
*
|
||||||
* PERFORMANCE NOTE (audit finding — DEFERRED, intentional):
|
* 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
|
* 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,
|
* 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:
|
* 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
|
* the file sizes are small (PDFs), the call sites are low-frequency admin
|
||||||
* admin operations, and the synchronous API lets callers treat a returned path
|
* operations, and the synchronous API lets callers treat a returned path as a
|
||||||
* as a verified, durable write (see migrate-received-invoices-to-nas.ts, which
|
* 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
|
* 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
|
* 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.
|
* 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_ISSUED = "Vydané";
|
||||||
const DIR_RECEIVED = "Přijaté";
|
const DIR_RECEIVED = "Přijaté";
|
||||||
|
|
||||||
class NasFinancialsManager {
|
export class NasDocumentManager {
|
||||||
private readonly basePath: string;
|
private readonly basePath: string;
|
||||||
|
|
||||||
constructor() {
|
constructor(basePath: string) {
|
||||||
const raw = config.nas.financialsPath;
|
this.basePath = basePath ? path.resolve(basePath).replace(/\\/g, "/") : "";
|
||||||
this.basePath = raw ? path.resolve(raw).replace(/\\/g, "/") : "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isConfigured(): boolean {
|
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 */
|
/** Remove any existing PDF for this document number across all year/month folders */
|
||||||
cleanIssuedInvoice(invoiceNumber: string): void {
|
cleanIssued(documentNumber: string): void {
|
||||||
if (!this.basePath) return;
|
if (!this.basePath) return;
|
||||||
const safeName = this.sanitizeFilename(invoiceNumber) + ".pdf";
|
const safeName = this.sanitizeFilename(documentNumber) + ".pdf";
|
||||||
const issuedDir = path.join(this.basePath, DIR_ISSUED);
|
const issuedDir = path.join(this.basePath, DIR_ISSUED);
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(issuedDir)) return;
|
if (!fs.existsSync(issuedDir)) return;
|
||||||
@@ -80,20 +82,20 @@ class NasFinancialsManager {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
"[nas-financials-manager] cleanIssuedInvoice failed for:",
|
"[nas-financials-manager] cleanIssued failed for:",
|
||||||
invoiceNumber,
|
documentNumber,
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
saveIssuedInvoicePdf(
|
saveIssuedPdf(
|
||||||
invoiceNumber: string,
|
documentNumber: string,
|
||||||
year: number,
|
year: number,
|
||||||
month: number,
|
month: number,
|
||||||
pdfBuffer: Buffer,
|
pdfBuffer: Buffer,
|
||||||
): string | null {
|
): 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
|
// (empty) basePath would let ensureDir build a RELATIVE path and write the
|
||||||
// PDF into the process CWD instead of the NAS. Bail out instead.
|
// PDF into the process CWD instead of the NAS. Bail out instead.
|
||||||
if (!this.isConfigured()) return null;
|
if (!this.isConfigured()) return null;
|
||||||
@@ -101,7 +103,7 @@ class NasFinancialsManager {
|
|||||||
const dir = this.ensureDir(DIR_ISSUED, year, month);
|
const dir = this.ensureDir(DIR_ISSUED, year, month);
|
||||||
if (!dir) return null;
|
if (!dir) return null;
|
||||||
|
|
||||||
const safeName = this.sanitizeFilename(invoiceNumber) + ".pdf";
|
const safeName = this.sanitizeFilename(documentNumber) + ".pdf";
|
||||||
const fullPath = this.uniquePath(dir, safeName);
|
const fullPath = this.uniquePath(dir, safeName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -109,7 +111,7 @@ class NasFinancialsManager {
|
|||||||
return `${DIR_ISSUED}/${year}/${this.pad(month)}/${path.basename(fullPath)}`;
|
return `${DIR_ISSUED}/${year}/${this.pad(month)}/${path.basename(fullPath)}`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
"[nas-financials-manager] write failed for issued invoice:",
|
"[nas-financials-manager] write failed for issued document:",
|
||||||
fullPath,
|
fullPath,
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
@@ -117,9 +119,7 @@ class NasFinancialsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
readIssuedInvoice(
|
readIssued(relativePath: string): { data: Buffer; fileName: string } | null {
|
||||||
relativePath: string,
|
|
||||||
): { data: Buffer; fileName: string } | null {
|
|
||||||
const fullPath = this.resolveSafePath(relativePath);
|
const fullPath = this.resolveSafePath(relativePath);
|
||||||
if (!fullPath) return null;
|
if (!fullPath) return null;
|
||||||
try {
|
try {
|
||||||
@@ -127,7 +127,7 @@ class NasFinancialsManager {
|
|||||||
return { data, fileName: path.basename(fullPath) };
|
return { data, fileName: path.basename(fullPath) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
"[nas-financials-manager] read failed for issued invoice:",
|
"[nas-financials-manager] read failed for issued document:",
|
||||||
fullPath,
|
fullPath,
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
@@ -135,7 +135,7 @@ class NasFinancialsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteIssuedInvoice(relativePath: string): boolean {
|
deleteIssued(relativePath: string): boolean {
|
||||||
const fullPath = this.resolveSafePath(relativePath);
|
const fullPath = this.resolveSafePath(relativePath);
|
||||||
if (!fullPath) return false;
|
if (!fullPath) return false;
|
||||||
try {
|
try {
|
||||||
@@ -143,7 +143,7 @@ class NasFinancialsManager {
|
|||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
"[nas-financials-manager] delete failed for issued invoice:",
|
"[nas-financials-manager] delete failed for issued document:",
|
||||||
fullPath,
|
fullPath,
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
@@ -151,21 +151,21 @@ class NasFinancialsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Received invoices ────────────────────────────────────────────
|
// ── Received documents ───────────────────────────────────────────
|
||||||
|
|
||||||
buildReceivedPath(fileName: string, year: number, month: number): string {
|
buildReceivedPath(fileName: string, year: number, month: number): string {
|
||||||
const safeName = this.sanitizeFilename(fileName) || "file";
|
const safeName = this.sanitizeFilename(fileName) || "file";
|
||||||
return `${DIR_RECEIVED}/${year}/${this.pad(month)}/${safeName}`;
|
return `${DIR_RECEIVED}/${year}/${this.pad(month)}/${safeName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveReceivedInvoice(
|
saveReceived(
|
||||||
originalName: string,
|
originalName: string,
|
||||||
year: number,
|
year: number,
|
||||||
month: number,
|
month: number,
|
||||||
fileBuffer: Buffer,
|
fileBuffer: Buffer,
|
||||||
): { filePath: string } | { error: string } {
|
): { filePath: string } | { error: string } {
|
||||||
if (!this.isConfigured()) {
|
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);
|
const dir = this.ensureDir(DIR_RECEIVED, year, month);
|
||||||
@@ -188,9 +188,7 @@ class NasFinancialsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
readReceivedInvoice(
|
readReceived(filePath: string): { data: Buffer; fileName: string } | null {
|
||||||
filePath: string,
|
|
||||||
): { data: Buffer; fileName: string } | null {
|
|
||||||
const fullPath = this.resolveSafePath(filePath);
|
const fullPath = this.resolveSafePath(filePath);
|
||||||
if (!fullPath) return null;
|
if (!fullPath) return null;
|
||||||
|
|
||||||
@@ -199,7 +197,7 @@ class NasFinancialsManager {
|
|||||||
return { data, fileName: path.basename(fullPath) };
|
return { data, fileName: path.basename(fullPath) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
"[nas-financials-manager] read failed for received invoice:",
|
"[nas-financials-manager] read failed for received document:",
|
||||||
fullPath,
|
fullPath,
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
@@ -207,7 +205,7 @@ class NasFinancialsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteReceivedInvoice(filePath: string): boolean {
|
deleteReceived(filePath: string): boolean {
|
||||||
const fullPath = this.resolveSafePath(filePath);
|
const fullPath = this.resolveSafePath(filePath);
|
||||||
if (!fullPath) return false;
|
if (!fullPath) return false;
|
||||||
|
|
||||||
@@ -216,7 +214,7 @@ class NasFinancialsManager {
|
|||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
"[nas-financials-manager] delete failed for received invoice:",
|
"[nas-financials-manager] delete failed for received document:",
|
||||||
fullPath,
|
fullPath,
|
||||||
err,
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user