Factory-Defaults: Export + Import von Stammdaten-Katalogen
Ein neues System um Stammdaten-Kataloge zwischen Installationen zu teilen – explizit ohne Kundendaten, Verträge oder Einstellungen. **Was wird exportiert:** - Anbieter + zugehörige Tarife - Kündigungsfristen - Vertragslaufzeiten - Vertragskategorien - PDF-Auftragsvorlagen (JSON + PDF-Dateien + Feldzuordnungen) **Was NICHT:** - Kundendaten, Verträge, Dokumente, Emails, SMTP-Einstellungen → dafür gibt es den Datenbank-Backup **Neue Einstellungsseite /settings/factory-defaults:** - Zeigt Anzahl pro Kategorie (Anbieter, Tarife, Fristen, …) - "Exportieren"-Button lädt ZIP herunter (manifest.json + JSONs + PDFs) - Import-Anleitung inline **Import-Script:** - `npm run seed:defaults` (tsx scripts/seed-factory-defaults.ts) - Liest alle JSON-Dateien aus backend/factory-defaults/*/*.json - Merged mehrere Dateien automatisch pro Kategorie (unique-key gewinnt zuletzt) - Upsertet idempotent → kann mehrfach ausgeführt werden - Kopiert PDF-Vorlagen aus factory-defaults/pdf-templates/ nach uploads/pdf-templates/ - Alte PDF-Dateien werden beim Re-Import entsorgt Backend: - services/factoryDefaults.service.ts: collectFactoryDefaults() + exportFactoryDefaults() - controllers/factoryDefaults.controller.ts: preview + export - routes/factoryDefaults.routes.ts: GET /api/factory-defaults/preview + /export - scripts/seed-factory-defaults.ts: CLI-Import-Script - .gitignore: factory-defaults/* außer .gitkeep und README.md Frontend: - pages/settings/FactoryDefaults.tsx: Übersicht + Export-Button - Settings-Karte „Factory-Defaults" im System-Abschnitt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Factory-Defaults: Export + Import von Stammdaten-Katalogen.
|
||||
* Enthält KEINE Kundendaten, Verträge, Dokumente oder Einstellungen –
|
||||
* nur reine Kataloge: Anbieter, Tarife, Kündigungsfristen, Laufzeiten,
|
||||
* Vertragskategorien und PDF-Auftragsvorlagen.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import archiver from 'archiver';
|
||||
import prisma from '../lib/prisma.js';
|
||||
|
||||
export interface FactoryDefaultsManifest {
|
||||
version: 1;
|
||||
exportedAt: string;
|
||||
counts: {
|
||||
providers: number;
|
||||
tariffs: number;
|
||||
cancellationPeriods: number;
|
||||
contractDurations: number;
|
||||
contractCategories: number;
|
||||
pdfTemplates: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProviderExport {
|
||||
name: string;
|
||||
portalUrl: string | null;
|
||||
usernameFieldName: string | null;
|
||||
passwordFieldName: string | null;
|
||||
isActive: boolean;
|
||||
tariffs: { name: string; isActive: boolean }[];
|
||||
}
|
||||
|
||||
export interface PdfTemplateExport {
|
||||
name: string;
|
||||
description: string | null;
|
||||
providerName: string | null;
|
||||
originalName: string;
|
||||
fieldMapping: any;
|
||||
phoneFieldPrefix: string | null;
|
||||
maxPhoneFields: number | null;
|
||||
isActive: boolean;
|
||||
// Datei wird separat im ZIP abgelegt unter pdf-templates/<name>.pdf
|
||||
pdfFilename: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammelt alle Katalog-Daten aus der DB.
|
||||
*/
|
||||
export async function collectFactoryDefaults() {
|
||||
const [providers, cancellationPeriods, contractDurations, contractCategories, pdfTemplates] =
|
||||
await Promise.all([
|
||||
prisma.provider.findMany({
|
||||
include: { tariffs: { select: { name: true, isActive: true } } },
|
||||
orderBy: { name: 'asc' },
|
||||
}),
|
||||
prisma.cancellationPeriod.findMany({ orderBy: { code: 'asc' } }),
|
||||
prisma.contractDuration.findMany({ orderBy: { code: 'asc' } }),
|
||||
prisma.contractCategory.findMany({ orderBy: { sortOrder: 'asc' } }),
|
||||
prisma.pdfTemplate.findMany({ orderBy: { name: 'asc' } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
providers: providers.map<ProviderExport>((p) => ({
|
||||
name: p.name,
|
||||
portalUrl: p.portalUrl,
|
||||
usernameFieldName: p.usernameFieldName,
|
||||
passwordFieldName: p.passwordFieldName,
|
||||
isActive: p.isActive,
|
||||
tariffs: p.tariffs.map((t) => ({ name: t.name, isActive: t.isActive })),
|
||||
})),
|
||||
cancellationPeriods: cancellationPeriods.map((c) => ({
|
||||
code: c.code,
|
||||
description: c.description,
|
||||
isActive: c.isActive,
|
||||
})),
|
||||
contractDurations: contractDurations.map((d) => ({
|
||||
code: d.code,
|
||||
description: d.description,
|
||||
isActive: d.isActive,
|
||||
})),
|
||||
contractCategories: contractCategories.map((c) => ({
|
||||
code: c.code,
|
||||
name: c.name,
|
||||
icon: c.icon,
|
||||
color: c.color,
|
||||
sortOrder: c.sortOrder,
|
||||
isActive: c.isActive,
|
||||
})),
|
||||
pdfTemplates: pdfTemplates.map<PdfTemplateExport>((t) => {
|
||||
// fieldMapping ist im DB als String abgelegt – zu Objekt parsen
|
||||
let fieldMapping: any = {};
|
||||
try {
|
||||
fieldMapping = JSON.parse(t.fieldMapping);
|
||||
} catch {
|
||||
fieldMapping = {};
|
||||
}
|
||||
return {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
providerName: t.providerName,
|
||||
originalName: t.originalName,
|
||||
fieldMapping,
|
||||
phoneFieldPrefix: t.phoneFieldPrefix,
|
||||
maxPhoneFields: t.maxPhoneFields,
|
||||
isActive: t.isActive,
|
||||
pdfFilename: sanitizeFilename(t.name) + path.extname(t.originalName || '.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]/g, '_').replace(/_+/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt ein ZIP-Archiv mit allen Factory-Defaults.
|
||||
* Die PDF-Dateien werden aus dem uploads-Verzeichnis gelesen.
|
||||
*/
|
||||
export async function exportFactoryDefaults(): Promise<Buffer> {
|
||||
const data = await collectFactoryDefaults();
|
||||
|
||||
const manifest: FactoryDefaultsManifest = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
counts: {
|
||||
providers: data.providers.length,
|
||||
tariffs: data.providers.reduce((sum, p) => sum + p.tariffs.length, 0),
|
||||
cancellationPeriods: data.cancellationPeriods.length,
|
||||
contractDurations: data.contractDurations.length,
|
||||
contractCategories: data.contractCategories.length,
|
||||
pdfTemplates: data.pdfTemplates.length,
|
||||
},
|
||||
};
|
||||
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
archive.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
archive.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
archive.on('error', reject);
|
||||
|
||||
// JSON-Dateien vorbereiten
|
||||
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' });
|
||||
archive.append(JSON.stringify(data.providers, null, 2), {
|
||||
name: 'providers/providers.json',
|
||||
});
|
||||
archive.append(JSON.stringify(data.cancellationPeriods, null, 2), {
|
||||
name: 'contract-meta/cancellation-periods.json',
|
||||
});
|
||||
archive.append(JSON.stringify(data.contractDurations, null, 2), {
|
||||
name: 'contract-meta/contract-durations.json',
|
||||
});
|
||||
archive.append(JSON.stringify(data.contractCategories, null, 2), {
|
||||
name: 'contract-meta/contract-categories.json',
|
||||
});
|
||||
archive.append(JSON.stringify(data.pdfTemplates, null, 2), {
|
||||
name: 'pdf-templates/pdf-templates.json',
|
||||
});
|
||||
|
||||
// PDF-Dateien physisch hinzufügen (Pfade aus DB laden)
|
||||
const uploadsRoot = path.join(process.cwd(), 'uploads');
|
||||
(async () => {
|
||||
try {
|
||||
const templateRows = await prisma.pdfTemplate.findMany({
|
||||
select: { name: true, templatePath: true },
|
||||
});
|
||||
const pathByName = new Map(templateRows.map((r) => [r.name, r.templatePath]));
|
||||
|
||||
for (const t of data.pdfTemplates) {
|
||||
const storedPath = pathByName.get(t.name);
|
||||
if (!storedPath) continue;
|
||||
|
||||
const relPath = storedPath.startsWith('/uploads/')
|
||||
? storedPath.substring('/uploads/'.length)
|
||||
: storedPath;
|
||||
const absPath = path.join(uploadsRoot, relPath);
|
||||
|
||||
if (fs.existsSync(absPath)) {
|
||||
archive.file(absPath, { name: `pdf-templates/${t.pdfFilename}` });
|
||||
} else {
|
||||
console.warn(`[factoryDefaults] PDF-Datei nicht gefunden: ${absPath}`);
|
||||
}
|
||||
}
|
||||
archive.finalize();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user