Files
Vorrania/web/src/pages/Transfer.jsx
Scarriffle 6e48cd0a7a Stammdaten-Export/Import (JSON) + Lagerort-Eltern bleibt beim Anlegen
- Backend: services/master_data.py exportiert/importiert Kategorien (inkl.
  eigener Felder), Lagerorte, Einheiten und Gebinde als JSON – MIT IDs, damit
  gedruckte QR-Codes und Verweise nach dem Wiederherstellen passen. Import-Modus
  skip (Vorhandenes lassen) oder overwrite (per ID aktualisieren); Namens-
  Konflikte werden uebersprungen, nicht abgebrochen. Postgres-Sequenzen werden
  danach angehoben. Endpunkte GET /export/master-data, POST /import/master-data.
  5 neue Tests, Suite 162 gruen.
- Web: neue Karte "Stammdaten sichern & wiederherstellen (JSON)" in Import/Export.
- Web: beim Lagerort-Anlegen bleibt der uebergeordnete Ort in der Auswahl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 11:17:14 +02:00

471 lines
20 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from "react";
import { api } from "../api";
import { useConfirm } from "../confirm";
import Icon from "../components/Icon";
import { asTree } from "../categoryTree";
// Baut die Etiketten-CSV mit wählbarem Trennzeichen (BOM für Excel-Umlaute).
// QR-Inhalt = Link aufs Stück.
function buildLabelCsv(rows, origin, delim = ",") {
const head = ["QR-Inhalt", "UID", "Produkt", "Marke", "Kategorie", "Lagerort"];
const delimRe = delim === "\t" ? "\\t" : delim;
const needsQuote = new RegExp(`[${delimRe}"\\n\\r]`);
const esc = (v) => {
let s = String(v ?? "");
// Formel-Injektion verhindern: Beginnt eine Zelle mit = + - @ (oder Tab/CR),
// koennte Excel/P-touch sie als Formel ausfuehren. Ein vorangestelltes
// Apostroph macht sie zu reinem Text.
if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
return needsQuote.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const lines = [head.map(esc).join(delim)];
for (const r of rows) {
lines.push([`${origin}/i/${r.uid}`, r.uid, r.product, r.brand, r.category, r.location]
.map(esc).join(delim));
}
return "" + lines.join("\r\n");
}
// Etiketten-CSV für Lagerorte (QR-Inhalt = Link auf den Ort). Gleiches Prinzip
// wie bei den Einzelstücken; „Pfad" nennt die Ober-Lagerorte (Hedingen → Keller).
function buildLocationCsv(locations, origin, delim = ",") {
const byId = Object.fromEntries(locations.map((l) => [l.id, l]));
const pfad = (l) => {
const teile = [l.name];
const gesehen = new Set([l.id]);
let cur = l.parent_id;
while (cur != null && byId[cur] && !gesehen.has(cur)) {
gesehen.add(cur);
teile.unshift(byId[cur].name);
cur = byId[cur].parent_id;
}
return teile.join(" → ");
};
const head = ["QR-Inhalt", "ID", "Lagerort", "Pfad"];
const delimRe = delim === "\t" ? "\\t" : delim;
const needsQuote = new RegExp(`[${delimRe}"\\n\\r]`);
const esc = (v) => {
let s = String(v ?? "");
if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
return needsQuote.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const lines = [head.map(esc).join(delim)];
for (const l of locations) {
lines.push([`${origin}/l/${l.id}`, l.id, l.name, pfad(l)].map(esc).join(delim));
}
return "" + lines.join("\r\n");
}
export default function Transfer() {
const confirm = useConfirm();
const fileRef = useRef(null);
const [file, setFile] = useState(null);
const [mode, setMode] = useState("add");
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
// Etiketten-Export: Kategorieauswahl (leer = alle).
const [categories, setCategories] = useState([]);
const [selCats, setSelCats] = useState(() => new Set());
const [labelBusy, setLabelBusy] = useState(false);
const [labelInfo, setLabelInfo] = useState(null);
// Gemerktes Trennzeichen (Standard Komma P-touch mag das lieber).
const [labelDelim, setLabelDelim] = useState(() => localStorage.getItem("label_delim") || ",");
// Lagerort-Etiketten.
const [locations, setLocations] = useState([]);
const [locBusy, setLocBusy] = useState(false);
const [locInfo, setLocInfo] = useState(null);
// Stammdaten-Sicherung (Kategorien/Lagerorte/Einheiten/Gebinde als JSON).
const mdFileRef = useRef(null);
const [mdMode, setMdMode] = useState("skip");
const [mdBusy, setMdBusy] = useState(false);
const [mdInfo, setMdInfo] = useState(null);
useEffect(() => {
api.listCategories().then(setCategories).catch(() => {});
api.listLocations().then(setLocations).catch(() => {});
}, []);
function downloadCsv(csv, filename) {
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function exportMasterData() {
setError(null); setMdInfo(null); setMdBusy(true);
try {
const data = await api.exportMasterData();
downloadCsv(JSON.stringify(data, null, 2), "vorrania-stammdaten.json");
setMdInfo("Stammdaten exportiert.");
} catch (err) { setError(err.message); } finally { setMdBusy(false); }
}
async function importMasterData(e) {
e.preventDefault();
const f = mdFileRef.current?.files?.[0];
if (!f) return;
setError(null); setMdInfo(null); setMdBusy(true);
try {
const data = JSON.parse(await f.text());
const rep = await api.importMasterData(data, mdMode);
const summe = (o) => Object.values(o || {}).reduce((a, b) => a + b, 0);
setMdInfo(`Import fertig: ${summe(rep.created)} angelegt, ${summe(rep.updated)} aktualisiert, ${summe(rep.skipped)} übersprungen.`);
if (mdFileRef.current) mdFileRef.current.value = "";
api.listLocations().then(setLocations).catch(() => {});
api.listCategories().then(setCategories).catch(() => {});
} catch (err) {
setError(err instanceof SyntaxError ? "Datei ist kein gültiges JSON." : err.message);
} finally { setMdBusy(false); }
}
function exportLocationLabels() {
setError(null);
setLocInfo(null);
setLocBusy(true);
try {
if (!locations.length) { setLocInfo("Keine Lagerorte vorhanden."); return; }
downloadCsv(buildLocationCsv(locations, window.location.origin, labelDelim),
"vorrania-lagerort-etiketten.csv");
setLocInfo(`${locations.length} Lagerort-Etikett(en) exportiert.`);
} catch (err) {
setError(err.message);
} finally {
setLocBusy(false);
}
}
function changeDelim(v) {
setLabelDelim(v);
localStorage.setItem("label_delim", v);
}
function toggleCat(id, on) {
setSelCats((prev) => {
const neu = new Set(prev);
if (on) neu.add(id); else neu.delete(id);
return neu;
});
}
async function exportLabels() {
setError(null);
setLabelInfo(null);
setLabelBusy(true);
try {
const rows = await api.labelRows([...selCats]);
if (!rows.length) { setLabelInfo("Keine Einzelstücke in der Auswahl."); return; }
const csv = buildLabelCsv(rows, window.location.origin, labelDelim);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "vorrania-etiketten.csv";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
setLabelInfo(`${rows.length} Einzelstück(e) exportiert.`);
} catch (err) {
setError(err.message);
} finally {
setLabelBusy(false);
}
}
const MODES = {
add: "Nur hinzufügen es wird nichts gelöscht.",
replace_listed:
"Bestand der Produkte ersetzen, die in der Datei vorkommen. Ideal für eine Inventur: exportieren, korrigieren, zurückspielen.",
replace_all:
"Alle Bestände vorher leeren und komplett neu aufbauen. Für die vollständige Wiederherstellung aus einem Backup.",
};
async function download(kind) {
setError(null);
try {
if (kind === "csv") await api.exportCsv();
else await api.exportJson();
} catch (err) {
setError(err.message);
}
}
async function doImport(e) {
e.preventDefault();
if (!file) return;
if (mode !== "add") {
const warning =
mode === "replace_all"
? "ALLE Bestände werden vorher gelöscht und aus der Datei neu aufgebaut. Fortfahren?"
: "Für alle Produkte in der Datei werden die vorhandenen Chargen gelöscht und ersetzt. Fortfahren?";
if (!(await confirm({
title: "Vorhandene Bestände ersetzen?",
message: warning,
confirmLabel: "Ersetzen",
danger: true,
}))) return;
}
setError(null);
setResult(null);
setBusy(true);
try {
setResult(await api.importStock(file, mode));
setFile(null);
if (fileRef.current) fileRef.current.value = "";
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
// Gegenstands-Kategorien als Baum; für die Anzeige, welche Unterkategorien
// durch eine gewählte Oberkategorie automatisch mitkommen.
const objTree = asTree(categories).filter((c) => c.tracking === "object");
const parentOf = Object.fromEntries(categories.map((c) => [c.id, c.parent_id]));
const nameById = Object.fromEntries(categories.map((c) => [c.id, c.name]));
const hatKinder = (id) => objTree.some((o) => o.parent_id === id);
// Name der nächsten ausgewählten Oberkategorie (oder null) dann ist diese
// Kategorie „über die Oberkategorie enthalten".
function abgedecktVon(id) {
let p = parentOf[id];
while (p != null) {
if (selCats.has(p)) return nameById[p];
p = parentOf[p];
}
return null;
}
return (
<div>
<div className="page-head">
<div>
<h1>Import / Export</h1>
<div className="sub">Bestände samt Chargen und MHD sichern oder einspielen</div>
</div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="grid-2">
<section className="card">
<div className="card-head"><Icon name="checkout" /><h2>Export</h2></div>
<p className="muted small mt-0">
<strong>CSV</strong> enthält eine Zeile je Charge und lässt sich in Excel oder
LibreOffice bearbeiten. <strong>JSON</strong> ist ein vollständiges Backup inklusive
Einheiten, Gruppen und Lagerorten.
</p>
<div className="field-inline">
<button className="btn primary" onClick={() => download("csv")}>
<Icon name="package" size={16} />Bestand als CSV
</button>
<button className="btn" onClick={() => download("json")}>
<Icon name="box" size={16} />Backup als JSON
</button>
</div>
</section>
<section className="card">
<div className="card-head"><Icon name="checkin" /><h2>Import</h2></div>
<p className="muted small mt-0">
CSV oder JSON auswählen. Unbekannte Produkte, Gruppen und Lagerorte werden immer
angelegt was mit vorhandenen Beständen passiert, bestimmt der Modus.
</p>
<form onSubmit={doImport}>
<label>
Datei
<input ref={fileRef} type="file" accept=".csv,.json,text/csv,application/json"
onChange={(e) => setFile(e.target.files?.[0] || null)} />
</label>
<label>
Modus
<select value={mode} onChange={(e) => setMode(e.target.value)}>
<option value="add">Nur hinzufügen</option>
<option value="replace_listed">Bestände der enthaltenen Produkte ersetzen</option>
<option value="replace_all">Alle Bestände ersetzen</option>
</select>
</label>
<p className={`muted small mt-0 ${mode === "replace_all" ? "hint-danger" : ""}`}>
{MODES[mode]}
</p>
<button className="btn primary" disabled={!file || busy}>
{busy ? "Importiere…" : "Importieren"}
</button>
</form>
{result && (
<div className="alert ok" style={{ marginTop: "var(--sp-4)" }}>
<Icon name="check" size={16} />
<span>
Import abgeschlossen ({result.format?.toUpperCase()}):{" "}
{result.products_created} Produkt(e) angelegt, {result.lots_added} Charge(n) ergänzt
{result.products_cleared ? `, ${result.products_cleared} Produkt(e) vorher geleert` : ""}
{result.units_created ? `, ${result.units_created} Einheit(en) angelegt` : ""}.
</span>
</div>
)}
{result?.errors?.length > 0 && (
<div className="alert warn">
<Icon name="alert" size={16} />
<span>
{result.errors.length} Zeile(n) übersprungen:
<ul style={{ margin: "6px 0 0", paddingLeft: 18 }}>
{result.errors.slice(0, 10).map((e, i) => <li key={i}>{e}</li>)}
</ul>
{result.errors.length > 10 && <div> und {result.errors.length - 10} weitere.</div>}
</span>
</div>
)}
</section>
</div>
<section className="card">
<div className="card-head"><Icon name="box" /><h2>Stammdaten sichern &amp; wiederherstellen (JSON)</h2></div>
<p className="muted small mt-0">
Kategorien (inkl. eigener Felder), Lagerorte, Einheiten und Gebinde als <strong>eine
JSON-Datei</strong>. Die <strong>IDs kommen mit</strong> so passen gedruckte
Lagerort-QR-Codes nach dem Wiederherstellen noch und Verweise bleiben gültig. Gut für
Umzug auf einen neuen Server oder als Sicherung. (Artikel/Bestände: siehe Backup als
JSON" oben.)
</p>
<div className="grid-2">
<div>
<button className="btn primary" disabled={mdBusy} onClick={exportMasterData}>
<Icon name="download" size={16} />Stammdaten exportieren
</button>
</div>
<form onSubmit={importMasterData}>
<label>
Datei (JSON)
<input ref={mdFileRef} type="file" accept="application/json,.json" />
</label>
<label>
Bei bereits vergebener ID
<select value={mdMode} onChange={(e) => setMdMode(e.target.value)}>
<option value="skip">Vorhandenes behalten (nur Neues anlegen)</option>
<option value="overwrite">Überschreiben (per ID aktualisieren)</option>
</select>
</label>
<button className="btn" disabled={mdBusy}>
<Icon name="checkin" size={16} />{mdBusy ? "Arbeite" : "Stammdaten importieren"}
</button>
</form>
</div>
{mdInfo && <p className="muted small">{mdInfo}</p>}
</section>
<section className="card">
<div className="card-head"><Icon name="tag" /><h2>QR-Etiketten-Export (P-touch &amp; Co.)</h2></div>
<p className="muted small mt-0">
Je Einzelstück eine Zeile: <strong>QR-Inhalt</strong>, UID, Produkt, Marke, Kategorie,
Lagerort. In der Etiketten-Software (z.B. P-touch) als <strong>Datenbank</strong>{" "}
verknüpfen sie erzeugt QR-Code und Text automatisch. Ohne Auswahl kommen alle
Einzelstücke. Wählst du eine Kategorie, kommen ihre <strong>Unterkategorien
automatisch mit</strong> sie erscheinen unten als „enthalten".
</p>
<label style={{ maxWidth: 340 }}>
Trennzeichen (für die Etiketten-Software)
<select value={labelDelim} onChange={(e) => changeDelim(e.target.value)}>
<option value=",">Komma ( , ) z.B. P-touch</option>
<option value=";">Semikolon ( ; ) z.B. Excel (Deutsch)</option>
<option value={"\t"}>Tabulator</option>
</select>
</label>
<div className="kat-check-liste">
{objTree.map((c) => {
const enthalten = abgedecktVon(c.id);
const direkt = selCats.has(c.id);
return (
<label key={c.id}
className={`check-inline ${enthalten ? "kat-enthalten" : ""}`}
style={{ paddingLeft: c.depth * 18 }}
title={enthalten ? `über „${enthalten}" enthalten` : undefined}>
<input type="checkbox" checked={direkt || enthalten != null}
disabled={enthalten != null}
onChange={(e) => toggleCat(c.id, e.target.checked)} />
<span>{c.name}</span>
{enthalten != null && (
<span className="badge nowrap">enthalten</span>
)}
{enthalten == null && direkt && hatKinder(c.id) && (
<span className="badge nowrap">inkl. Unterkategorien</span>
)}
{enthalten == null && !direkt && hatKinder(c.id) && (
<span className="muted small nowrap">+ Unterkategorien</span>
)}
</label>
);
})}
{objTree.length === 0 && (
<span className="muted small">Noch keine Gegenstands-Kategorien.</span>
)}
</div>
<div className="field-inline" style={{ marginTop: "var(--sp-3)" }}>
<button className="btn primary" disabled={labelBusy} onClick={exportLabels}>
<Icon name="download" size={16} />{labelBusy ? "Exportiere…" : "Etiketten-CSV herunterladen"}
</button>
{selCats.size > 0 && (
<button type="button" className="btn ghost" onClick={() => setSelCats(new Set())}>
Auswahl zurücksetzen ({selCats.size})
</button>
)}
</div>
{labelInfo && <p className="muted small">{labelInfo}</p>}
</section>
<section className="card">
<div className="card-head"><Icon name="location" /><h2>Lagerort-Etiketten-Export (P-touch &amp; Co.)</h2></div>
<p className="muted small mt-0">
Je Lagerort eine Zeile: <strong>QR-Inhalt</strong>, ID, Lagerort und der{" "}
<strong>Pfad</strong> (z.B. Hedingen Keller"). In der Etiketten-Software als{" "}
<strong>Datenbank</strong> verknüpfen. Verwendet dasselbe Trennzeichen wie oben.
Ein gescannter Lagerort-QR öffnet den Ort in der App/Weboberfläche.
</p>
<div className="field-inline">
<button className="btn primary" disabled={locBusy || !locations.length} onClick={exportLocationLabels}>
<Icon name="download" size={16} />{locBusy ? "Exportiere…" : "Lagerort-Etiketten-CSV herunterladen"}
</button>
<span className="muted small" style={{ alignSelf: "center" }}>
{locations.length} Lagerort{locations.length === 1 ? "" : "e"}
</span>
</div>
{locInfo && <p className="muted small">{locInfo}</p>}
</section>
<section className="card">
<div className="card-head"><Icon name="package" /><h2>CSV-Aufbau</h2></div>
<div className="table-wrap">
<table className="table table-compact">
<thead><tr><th>Spalte</th><th>Bedeutung</th></tr></thead>
<tbody>
<tr><td className="strong">barcode</td><td>optional; dient zum Wiedererkennen vorhandener Produkte</td></tr>
<tr><td className="strong">name</td><td>Produktname (Pflicht, wenn kein Barcode passt)</td></tr>
<tr><td className="strong">marke</td><td>optional</td></tr>
<tr><td className="strong">einheit</td><td>Produkteinheit, z.B. Gramm, Kilogramm, Stück</td></tr>
<tr><td className="strong">packungsgroesse</td><td>Basiseinheiten je Gebinde, z.B. 195</td></tr>
<tr><td className="strong">gebinde</td><td>Bezeichnung, z.B. Glas, Tüte, Flasche</td></tr>
<tr><td className="strong">gruppe</td><td>optional; wird bei Bedarf angelegt</td></tr>
<tr><td className="strong">mindestbestand</td><td>optional, in Basiseinheiten</td></tr>
<tr><td className="strong">menge</td><td>Menge dieser Charge</td></tr>
<tr><td className="strong">menge_einheit</td><td>Einheit der Menge: Gebinde-Bezeichnung oder z.B. Gramm</td></tr>
<tr><td className="strong">mhd</td><td>Datum, z.B. 2026-12-23 oder 23.12.2026 leer erlaubt</td></tr>
<tr><td className="strong">lagerort</td><td>optional; wird bei Bedarf angelegt</td></tr>
</tbody>
</table>
</div>
<p className="muted small">
Am einfachsten: einmal exportieren, die Datei als Vorlage nehmen und ergänzen.
Trennzeichen ist ein Semikolon; Komma wird ebenfalls erkannt.
</p>
</section>
</div>
);
}