feat(rechnungen): Laufzeiten bestaetigen, Rechnungen erfassen und Status setzen (#008)

Rechnungen mit Positionen: gleiche Nummer ergibt eine Sammelrechnung ueber mehrere
Laufzeiten. PDF-Upload mit Inhaltspruefung und Ablage unter der Pruefsumme,
Haekchen direkt in der Tabelle, Filter nach Status, Kunde und Jahr. 78 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 21:54:45 +02:00
co-authored by Claude Opus 5
parent 1e1d6c4418
commit 558dfea34c
23 changed files with 2015 additions and 86 deletions
+53
View File
@@ -0,0 +1,53 @@
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { prisma } from "./db";
/** Rechnungs-PDFs liegen auf der Platte, nur die Metadaten in der Datenbank. */
export const UPLOAD_VERZEICHNIS = process.env.UPLOAD_DIR ?? path.join(process.cwd(), "data", "uploads");
export const MAX_GROESSE = 10 * 1024 * 1024;
export class UploadFehler extends Error {}
/**
* Nimmt ein PDF entgegen und legt es unter seiner Prüfsumme ab.
* Gleiche Datei zweimal hochgeladen heißt: derselbe Anhang, kein zweites Exemplar auf der Platte.
*/
export async function anhangSpeichern(datei: File): Promise<string> {
if (datei.size === 0) throw new UploadFehler("Die Datei ist leer.");
if (datei.size > MAX_GROESSE) {
throw new UploadFehler(`Die Datei ist größer als ${MAX_GROESSE / 1024 / 1024} MB.`);
}
const inhalt = Buffer.from(await datei.arrayBuffer());
// Nicht auf den gemeldeten Typ verlassen, sondern in die Datei sehen.
if (inhalt.subarray(0, 5).toString("latin1") !== "%PDF-") {
throw new UploadFehler("Nur PDF-Dateien sind erlaubt.");
}
const sha256 = createHash("sha256").update(inhalt).digest("hex");
const vorhanden = await prisma.anhang.findFirst({ where: { sha256 } });
if (vorhanden) return vorhanden.id;
await mkdir(UPLOAD_VERZEICHNIS, { recursive: true });
const dateiname = `${sha256}.pdf`;
await writeFile(path.join(UPLOAD_VERZEICHNIS, dateiname), inhalt, { mode: 0o640 });
const anhang = await prisma.anhang.create({
data: {
dateiname: datei.name || dateiname,
pfad: dateiname,
mimeTyp: "application/pdf",
groesse: inhalt.length,
sha256,
},
});
return anhang.id;
}
export function anhangPfad(pfad: string): string {
// Nur der Dateiname aus der Datenbank wird verwendet; Pfadangaben darin werden verworfen.
return path.join(UPLOAD_VERZEICHNIS, path.basename(pfad));
}