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:
BOHA
2026-06-09 20:52:06 +02:00
parent 313d9218c1
commit 841ab676ec
11 changed files with 158 additions and 62 deletions

View File

@@ -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);