Phase 1: Stammdatenverwaltung
Ab hier fuehrt die Wehr ihren Bestand in FLORIAN. Datenmodell: - Standort, Geraeteraum, Geraetetyp, Qualifikationstyp und Qualifikation - Fahrzeug um Fahrgestellnummer, DIN-Typ, Erstzulassung, Sitzplaetze, Tankinhalt und UVV-Frist erweitert - Geraet um Geraetetyp, Lebensdauerkategorie, errechnetes Lebensdauerende, Anschaffungspreis und die Lagerung erweitert - Ein Geraet steht entweder an einem Standort oder ist auf einem Fahrzeug verlastet. Eine Pruefbedingung setzt das durch, ein Trigger stellt zusaetzlich sicher, dass ein angegebener Geraeteraum zum angegebenen Fahrzeug gehoert. - RLS auf allen neuen Tabellen, Trigram-Indizes fuer die Volltextsuche Fachlogik: - lib/stammdaten/fristen.ts rechnet Prueftermine, Lebensdauer und den abgeleiteten Geraetestatus. Der Geraetetyp gibt vor, das Geraet darf abweichen. - lib/stammdaten/qr.ts und qr-parser.ts erzeugen und lesen die Codes - lib/stammdaten/etiketten.ts baut den A4-Bogen mit 65 Etiketten - lib/stammdaten/import.ts liest CSV und Excel, erkennt deutsche Datums- und Zahlenformate, Windows-1252 und Dubletten innerhalb der Datei - lib/pdf.ts spricht Gotenberg an Oberflaeche: - Geraeteliste mit Volltextsuche, Statusfilter, serverseitiger Sortierung, Blaettern, Spaltenauswahl und Massenbearbeitung - Geraetedetail mit QR-Code, Pruefvorgabe aus dem Geraetetyp und Verlauf - Fahrzeugliste und -detail mit Beladung nach Beladeordnung - Mitgliederliste nach Abteilung, Mitgliedsdetail mit Qualifikationen - Qualifikationen mit Ablaufwarnung je Vorwarnzeit des Typs - Import-Assistent mit Zuordnung und Vorschau vor dem Uebernehmen - QR-Scanner ueber die Kamera, mit Eingabefeld als Rueckfallweg Behoben: - Fristenrechnung lief ueber startOfDay aus date-fns und damit in der Zeitzone des Servers. Ein DATE aus der Datenbank kommt als UTC-Mitternacht und wurde dadurch auf den Vortag geschoben — Fristen waeren einen Tag zu frueh faellig geworden. Jetzt eigene Kalenderarithmetik auf UTC-Bestandteilen. - ctx.sitzung blieb in tRPC-Prozeduren nullbar, weil t.middleware() die Verengung der vorigen Middleware verwirft. Middleware jetzt direkt an .use(). - CardTitle war ein div und damit keine Ueberschrift. Wer mit einer Sprachausgabe arbeitet, springt von Ueberschrift zu Ueberschrift; Karten tauchten in dieser Navigation nicht auf. Daten und Tests: - Seed um 3 Standorte, 20 Geraeteraeume, 14 Geraetetypen mit echten Pruefvorgaben, 49 Geraete und 107 Qualifikationen erweitert - 104 Vitest-Tests (vorher 32), 44 Playwright-Tests (vorher 10) - Anleitung um sieben bebilderte Abschnitte erweitert, Changelog, Devlog, Aufwandsschaetzung und Roadmap fortgeschrieben Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4b3021bb50
commit
26b62a3477
@@ -0,0 +1,344 @@
|
||||
import "server-only";
|
||||
import Papa from "papaparse";
|
||||
import ExcelJS from "exceljs";
|
||||
import {
|
||||
IMPORT_ARTEN,
|
||||
spaltenZuordnen,
|
||||
wertLesen,
|
||||
type ImportArt,
|
||||
type ImportFeld,
|
||||
} from "@/lib/stammdaten/import-felder";
|
||||
|
||||
/**
|
||||
* Datei-Import für Stammdaten.
|
||||
*
|
||||
* Ablauf in drei Schritten, und der mittlere ist der wichtige:
|
||||
*
|
||||
* 1. Datei einlesen — CSV oder Excel, Spaltenüberschriften erkennen
|
||||
* 2. Zuordnung und Vorschau — was landet wo, und was stimmt daran nicht
|
||||
* 3. Übernehmen — erst hier wird geschrieben
|
||||
*
|
||||
* Zwischen 2 und 3 sitzt ein Mensch. Ohne diese Vorschau würde eine Wehr dem
|
||||
* Ergebnis nicht trauen, und zu Recht: eine falsch zugeordnete Spalte
|
||||
* verteilt Seriennummern auf Inventarnummern, und das merkt man erst Wochen
|
||||
* später am Regal.
|
||||
*/
|
||||
|
||||
/** Höchstwerte, damit ein versehentlich gewähltes 200-MB-Archiv nicht den
|
||||
* Server beschäftigt. */
|
||||
export const MAX_DATEIGROESSE = 10 * 1024 * 1024;
|
||||
export const MAX_ZEILEN = 5_000;
|
||||
|
||||
export interface GeleseneTabelle {
|
||||
spalten: string[];
|
||||
zeilen: Record<string, string>[];
|
||||
/** Wie viele Zeilen die Datei hatte, bevor gekürzt wurde. */
|
||||
zeilenGesamt: number;
|
||||
gekuerzt: boolean;
|
||||
}
|
||||
|
||||
export interface ZellFehler {
|
||||
zeile: number;
|
||||
spalte: string;
|
||||
feld: string;
|
||||
meldung: string;
|
||||
}
|
||||
|
||||
export interface VorschauZeile {
|
||||
/** Zeilennummer in der Datei, 1-basiert ohne Überschrift. */
|
||||
nummer: number;
|
||||
werte: Record<string, unknown>;
|
||||
rohwerte: Record<string, string>;
|
||||
fehler: ZellFehler[];
|
||||
/** true, wenn diese Zeile übernommen werden kann. */
|
||||
uebernehmbar: boolean;
|
||||
}
|
||||
|
||||
export interface ImportVorschau {
|
||||
art: ImportArt;
|
||||
felder: ImportFeld[];
|
||||
spalten: string[];
|
||||
zuordnung: Record<string, string | null>;
|
||||
zeilen: VorschauZeile[];
|
||||
zeilenGesamt: number;
|
||||
gekuerzt: boolean;
|
||||
/** Pflichtfelder, denen keine Spalte zugeordnet ist. */
|
||||
fehlendePflichtfelder: string[];
|
||||
anzahlUebernehmbar: number;
|
||||
anzahlFehlerhaft: number;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Schritt 1: Datei einlesen
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Erkennt das Format an der Endung, ersatzweise am Inhalt. */
|
||||
export function formatErkennen(dateiname: string): "csv" | "xlsx" | null {
|
||||
const endung = dateiname.toLowerCase().split(".").pop();
|
||||
if (endung === "csv" || endung === "txt") return "csv";
|
||||
if (endung === "xlsx" || endung === "xlsm") return "xlsx";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV einlesen. Das Trennzeichen wird erraten — deutsche Tabellen kommen
|
||||
* meistens mit Semikolon, weil Excel das im deutschen Sprachraum so schreibt.
|
||||
*/
|
||||
export function csvLesen(inhalt: string): GeleseneTabelle {
|
||||
const ergebnis = Papa.parse<Record<string, string>>(inhalt, {
|
||||
header: true,
|
||||
skipEmptyLines: "greedy",
|
||||
delimiter: "",
|
||||
delimitersToGuess: [";", ",", "\t", "|"],
|
||||
transformHeader: (kopf) => kopf.trim(),
|
||||
});
|
||||
|
||||
const alleZeilen = (ergebnis.data ?? []).filter((zeile) =>
|
||||
Object.values(zeile).some((wert) => (wert ?? "").toString().trim() !== ""),
|
||||
);
|
||||
|
||||
const spalten = (ergebnis.meta.fields ?? []).filter((feld) => feld.trim() !== "");
|
||||
|
||||
return {
|
||||
spalten,
|
||||
zeilen: alleZeilen.slice(0, MAX_ZEILEN).map(zeileNormalisieren),
|
||||
zeilenGesamt: alleZeilen.length,
|
||||
gekuerzt: alleZeilen.length > MAX_ZEILEN,
|
||||
};
|
||||
}
|
||||
|
||||
/** Excel einlesen. Es wird immer das erste Arbeitsblatt genommen. */
|
||||
export async function excelLesen(daten: Buffer): Promise<GeleseneTabelle> {
|
||||
const mappe = new ExcelJS.Workbook();
|
||||
await mappe.xlsx.load(daten as unknown as ArrayBuffer);
|
||||
|
||||
const blatt = mappe.worksheets[0];
|
||||
if (!blatt) {
|
||||
return { spalten: [], zeilen: [], zeilenGesamt: 0, gekuerzt: false };
|
||||
}
|
||||
|
||||
const kopfzeile = blatt.getRow(1);
|
||||
const spalten: string[] = [];
|
||||
kopfzeile.eachCell({ includeEmpty: false }, (zelle, nummer) => {
|
||||
const text = zellenText(zelle.value).trim();
|
||||
spalten[nummer - 1] = text || `Spalte ${nummer}`;
|
||||
});
|
||||
|
||||
const zeilen: Record<string, string>[] = [];
|
||||
let gesamt = 0;
|
||||
|
||||
blatt.eachRow({ includeEmpty: false }, (zeile, nummer) => {
|
||||
if (nummer === 1) return;
|
||||
|
||||
const datensatz: Record<string, string> = {};
|
||||
let hatInhalt = false;
|
||||
|
||||
spalten.forEach((spalte, index) => {
|
||||
if (!spalte) return;
|
||||
const wert = zellenText(zeile.getCell(index + 1).value).trim();
|
||||
datensatz[spalte] = wert;
|
||||
if (wert) hatInhalt = true;
|
||||
});
|
||||
|
||||
if (!hatInhalt) return;
|
||||
gesamt += 1;
|
||||
if (zeilen.length < MAX_ZEILEN) zeilen.push(datensatz);
|
||||
});
|
||||
|
||||
return {
|
||||
spalten: spalten.filter(Boolean),
|
||||
zeilen,
|
||||
zeilenGesamt: gesamt,
|
||||
gekuerzt: gesamt > MAX_ZEILEN,
|
||||
};
|
||||
}
|
||||
|
||||
/** Excel liefert je nach Zelle Zahlen, Daten, Formelergebnisse oder Rich Text. */
|
||||
function zellenText(wert: ExcelJS.CellValue): string {
|
||||
if (wert === null || wert === undefined) return "";
|
||||
if (wert instanceof Date) {
|
||||
const tag = String(wert.getUTCDate()).padStart(2, "0");
|
||||
const monat = String(wert.getUTCMonth() + 1).padStart(2, "0");
|
||||
return `${tag}.${monat}.${wert.getUTCFullYear()}`;
|
||||
}
|
||||
if (typeof wert === "object") {
|
||||
if ("text" in wert && typeof wert.text === "string") return wert.text;
|
||||
if ("result" in wert) return zellenText(wert.result as ExcelJS.CellValue);
|
||||
if ("richText" in wert && Array.isArray(wert.richText)) {
|
||||
return wert.richText.map((teil) => teil.text).join("");
|
||||
}
|
||||
if ("hyperlink" in wert && typeof wert.text === "string") return wert.text;
|
||||
return "";
|
||||
}
|
||||
return String(wert);
|
||||
}
|
||||
|
||||
function zeileNormalisieren(zeile: Record<string, unknown>): Record<string, string> {
|
||||
const ergebnis: Record<string, string> = {};
|
||||
for (const [schluessel, wert] of Object.entries(zeile)) {
|
||||
ergebnis[schluessel.trim()] = (wert ?? "").toString().trim();
|
||||
}
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
/** Liest eine hochgeladene Datei, unabhängig vom Format. */
|
||||
export async function tabelleLesen(
|
||||
daten: Buffer,
|
||||
dateiname: string,
|
||||
): Promise<GeleseneTabelle> {
|
||||
if (daten.length > MAX_DATEIGROESSE) {
|
||||
throw new Error(
|
||||
`Die Datei ist zu groß (${Math.round(daten.length / 1024 / 1024)} MB, erlaubt sind ${MAX_DATEIGROESSE / 1024 / 1024} MB).`,
|
||||
);
|
||||
}
|
||||
|
||||
const format = formatErkennen(dateiname);
|
||||
if (format === "xlsx") return excelLesen(daten);
|
||||
if (format === "csv") return csvLesen(textAusPuffer(daten));
|
||||
|
||||
throw new Error(
|
||||
`Das Format von "${dateiname}" wird nicht unterstützt. Erlaubt sind CSV und Excel (.xlsx).`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Text aus dem Puffer. Deutsche Exporte kommen oft noch als Windows-1252 —
|
||||
* eine BOM oder ungültige UTF-8-Folgen verraten das.
|
||||
*/
|
||||
export function textAusPuffer(daten: Buffer): string {
|
||||
// UTF-8 mit BOM
|
||||
if (daten.length >= 3 && daten[0] === 0xef && daten[1] === 0xbb && daten[2] === 0xbf) {
|
||||
return daten.subarray(3).toString("utf8");
|
||||
}
|
||||
|
||||
const alsUtf8 = daten.toString("utf8");
|
||||
// Das Ersetzungszeichen zeigt an, dass es kein gültiges UTF-8 war.
|
||||
if (alsUtf8.includes("�")) {
|
||||
return daten.toString("latin1");
|
||||
}
|
||||
return alsUtf8;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Schritt 2: Zuordnung und Vorschau
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Erzeugt die Vorschau. Schreibt nichts — das ist der ganze Sinn.
|
||||
*
|
||||
* @param zuordnung optional; ohne Angabe wird automatisch zugeordnet
|
||||
*/
|
||||
export function vorschauErstellen(
|
||||
tabelle: GeleseneTabelle,
|
||||
art: ImportArt,
|
||||
zuordnung?: Record<string, string | null>,
|
||||
): ImportVorschau {
|
||||
const felder = IMPORT_ARTEN[art].felder;
|
||||
const zuord = zuordnung ?? spaltenZuordnen(tabelle.spalten, felder);
|
||||
|
||||
const zugeordneteFelder = new Set(
|
||||
Object.values(zuord).filter((wert): wert is string => Boolean(wert)),
|
||||
);
|
||||
|
||||
const fehlendePflichtfelder = felder
|
||||
.filter((feld) => feld.pflicht && !zugeordneteFelder.has(feld.schluessel))
|
||||
.map((feld) => feld.bezeichnung);
|
||||
|
||||
const zeilen: VorschauZeile[] = tabelle.zeilen.map((rohzeile, index) => {
|
||||
const werte: Record<string, unknown> = {};
|
||||
const fehler: ZellFehler[] = [];
|
||||
|
||||
for (const [spalte, feldSchluessel] of Object.entries(zuord)) {
|
||||
if (!feldSchluessel) continue;
|
||||
|
||||
const feld = felder.find((f) => f.schluessel === feldSchluessel);
|
||||
if (!feld) continue;
|
||||
|
||||
const ergebnis = wertLesen(feld, rohzeile[spalte] ?? "");
|
||||
|
||||
if (!ergebnis.ok) {
|
||||
fehler.push({
|
||||
zeile: index + 1,
|
||||
spalte,
|
||||
feld: feld.bezeichnung,
|
||||
meldung: ergebnis.fehler ?? "Ungültiger Wert",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ergebnis.wert !== undefined) werte[feldSchluessel] = ergebnis.wert;
|
||||
}
|
||||
|
||||
// Pflichtfelder, für die es gar keine Spalte gibt, melden wir je Zeile —
|
||||
// sonst sieht man in der Vorschau nicht, dass die Zeile unbrauchbar ist.
|
||||
for (const feld of felder) {
|
||||
if (!feld.pflicht) continue;
|
||||
if (werte[feld.schluessel] !== undefined) continue;
|
||||
if (fehler.some((f) => f.feld === feld.bezeichnung)) continue;
|
||||
|
||||
fehler.push({
|
||||
zeile: index + 1,
|
||||
spalte: "—",
|
||||
feld: feld.bezeichnung,
|
||||
meldung: `${feld.bezeichnung} fehlt`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
nummer: index + 1,
|
||||
werte,
|
||||
rohwerte: rohzeile,
|
||||
fehler,
|
||||
uebernehmbar: fehler.length === 0,
|
||||
};
|
||||
});
|
||||
|
||||
// Dubletten innerhalb der Datei aufspüren — die fallen sonst erst beim
|
||||
// Schreiben auf, und dann ist die halbe Datei schon drin.
|
||||
markiereDubletten(zeilen, art);
|
||||
|
||||
return {
|
||||
art,
|
||||
felder,
|
||||
spalten: tabelle.spalten,
|
||||
zuordnung: zuord,
|
||||
zeilen,
|
||||
zeilenGesamt: tabelle.zeilenGesamt,
|
||||
gekuerzt: tabelle.gekuerzt,
|
||||
fehlendePflichtfelder,
|
||||
anzahlUebernehmbar: zeilen.filter((zeile) => zeile.uebernehmbar).length,
|
||||
anzahlFehlerhaft: zeilen.filter((zeile) => !zeile.uebernehmbar).length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Welches Feld eine Zeile innerhalb der Datei eindeutig macht. */
|
||||
const SCHLUESSELFELD: Record<ImportArt, string> = {
|
||||
geraet: "inventarnummer",
|
||||
person: "mitgliedsnummer",
|
||||
fahrzeug: "funkrufname",
|
||||
};
|
||||
|
||||
function markiereDubletten(zeilen: VorschauZeile[], art: ImportArt): void {
|
||||
const feld = SCHLUESSELFELD[art];
|
||||
const gesehen = new Map<string, number>();
|
||||
|
||||
for (const zeile of zeilen) {
|
||||
const wert = zeile.werte[feld];
|
||||
if (typeof wert !== "string" || !wert) continue;
|
||||
|
||||
const schluessel = wert.toLowerCase();
|
||||
const zuerst = gesehen.get(schluessel);
|
||||
|
||||
if (zuerst !== undefined) {
|
||||
zeile.fehler.push({
|
||||
zeile: zeile.nummer,
|
||||
spalte: feld,
|
||||
feld,
|
||||
meldung: `Kommt in dieser Datei schon in Zeile ${zuerst} vor`,
|
||||
});
|
||||
zeile.uebernehmbar = false;
|
||||
} else {
|
||||
gesehen.set(schluessel, zeile.nummer);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user