';
tarife.forEach((tarif, index) => {
checkboxHtml += `
-
+
@@ -2627,37 +2736,49 @@ async function displayTarifoptionenAdmin() {
optionen.forEach(option => {
const provision = `${(option.provision_euro || 0).toFixed(2).replace('.', ',')}€`;
- // Handle both old and new format
- let tarifDisplay;
+ // tarifDisplayHtml is the rendered HTML; tarifPlain is the plain-text variant for the title attr.
+ let tarifDisplayHtml;
+ let tarifPlain;
if (option.tarif_names) {
- // New format with multiple tarifs
- tarifDisplay = option.tarif_names || '
Alle Tarife';
+ tarifPlain = option.tarif_names || 'Alle Tarife';
+ tarifDisplayHtml = option.tarif_names
+ ? escapeHtml(option.tarif_names)
+ : '
Alle Tarife';
} else {
- // Old format with single tarif
- tarifDisplay = option.tarif_id ?
- (option.tarif_name || 'Unbekannter Tarif') :
- '
Alle Tarife';
+ if (option.tarif_id) {
+ tarifPlain = option.tarif_name || 'Unbekannter Tarif';
+ tarifDisplayHtml = escapeHtml(tarifPlain);
+ } else {
+ tarifPlain = 'Alle Tarife';
+ tarifDisplayHtml = '
Alle Tarife';
+ }
}
- // For new format, we may not have anbieter_name directly
const anbieterName = option.anbieter_name || 'Mehrere Anbieter';
- const row = `
-
- | ${option.id} |
- ${anbieterName} |
- ${tarifDisplay} |
- ${option.name} |
- ${option.code || '-'} |
- ${provision} |
- ${option.beschreibung || '-'} |
-
-
-
- |
-
+ const tr = document.createElement('tr');
+ tr.innerHTML = `
+
${option.id} |
+
${escapeHtml(anbieterName)} |
+
${tarifDisplayHtml} |
+
${escapeHtml(option.name)} |
+
${escapeHtml(option.code || '-')} |
+
${escapeHtml(provision)} |
+
${escapeHtml(option.beschreibung || '-')} |
+
+
+
+ |
`;
- tbody.innerHTML += row;
+ tr.querySelectorAll('button[data-to-action]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const action = btn.dataset.toAction;
+ const id = parseInt(btn.dataset.id, 10);
+ if (action === 'edit') editTarifoption(id);
+ else if (action === 'delete') deleteTarifoption(id);
+ });
+ });
+ tbody.appendChild(tr);
});
} catch (error) {
console.error('Fehler beim Laden der Tarifoptionen:', error);
@@ -2749,7 +2870,7 @@ async function editTarifoption(optionId) {
const anbieterSelect = document.getElementById('editTarifoptionAnbieter');
anbieterSelect.innerHTML = '
';
anbieter.forEach(a => {
- anbieterSelect.innerHTML += `
`;
+ anbieterSelect.innerHTML += `
`;
});
// Try to get from new API first, fallback to old API
@@ -2949,16 +3070,16 @@ async function loadMitarbeiterProzentsaetzeAdmin() {
providerDiv.className = 'admin-card';
providerDiv.innerHTML = `
`;
container.appendChild(providerDiv);
-
+
const prozentsaetzeList = document.getElementById(`prozentsaetze-${a.id}`);
-
+
if (prozentsaetze.length === 0) {
prozentsaetzeList.innerHTML = '
Keine Mitarbeiter-Prozentsätze definiert
';
} else {
@@ -2967,16 +3088,19 @@ async function loadMitarbeiterProzentsaetzeAdmin() {
item.className = 'list-item';
item.innerHTML = `
- ${p.prozentsatz}%
- ${p.beschreibung ? `- ${p.beschreibung}` : ''}
-
Gültig ab: ${p.gueltig_ab}
+ ${escapeHtml(String(p.prozentsatz))}%
+ ${p.beschreibung ? `- ${escapeHtml(p.beschreibung)}` : ''}
+
Gültig ab: ${escapeHtml(p.gueltig_ab)}
-
`;
+ item.querySelector('button[data-mp-action]').addEventListener('click', (e) => {
+ deleteMitarbeiterProzentsatz(parseInt(e.currentTarget.dataset.id, 10));
+ });
prozentsaetzeList.appendChild(item);
});
}
@@ -2995,7 +3119,7 @@ async function loadMitarbeiterAnbieter() {
select.innerHTML = '
';
anbieter.forEach(a => {
- select.innerHTML += `
`;
+ select.innerHTML += `
`;
});
} catch (error) {
@@ -3081,21 +3205,21 @@ async function loadTarifProvisionsAdmin() {
tarifDiv.className = 'admin-card';
tarifDiv.innerHTML = `
`;
+ tarifDiv.querySelector('button[data-tp-action]').addEventListener('click', (e) => {
+ saveTarifProvision(parseInt(e.currentTarget.dataset.id, 10));
+ });
container.appendChild(tarifDiv);
}
diff --git a/routes/anbieter.js b/routes/anbieter.js
new file mode 100644
index 0000000..760c845
--- /dev/null
+++ b/routes/anbieter.js
@@ -0,0 +1,81 @@
+const express = require('express');
+const db = require('../db');
+const asyncHandler = require('../middleware/asyncHandler');
+
+const router = express.Router();
+
+router.get('/', asyncHandler(async (req, res) => {
+ const rows = await db.all('SELECT * FROM anbieter ORDER BY name');
+ res.json(rows);
+}));
+
+router.post('/', asyncHandler(async (req, res) => {
+ const { name } = req.body || {};
+ if (!name || !name.trim()) {
+ return res.status(400).json({ error: 'Name ist erforderlich' });
+ }
+ try {
+ const result = await db.run('INSERT INTO anbieter (name) VALUES (?)', [name.trim()]);
+ res.json({ id: result.lastID, name: name.trim() });
+ } catch (err) {
+ if (/UNIQUE/.test(err.message)) {
+ return res.status(400).json({ error: 'Anbieter mit diesem Namen existiert bereits' });
+ }
+ throw err;
+ }
+}));
+
+router.delete('/:id', asyncHandler(async (req, res) => {
+ const { id } = req.params;
+
+ const [tariffeRow, schaltungenRow] = await Promise.all([
+ db.get('SELECT COUNT(*) AS n FROM tarife WHERE anbieter_id = ?', [id]),
+ db.get('SELECT COUNT(*) AS n FROM schaltungen WHERE anbieter_id = ?', [id]),
+ ]);
+
+ if (tariffeRow.n + schaltungenRow.n > 0) {
+ const reasons = [];
+ if (tariffeRow.n > 0) reasons.push(`${tariffeRow.n} Tarif(e)`);
+ if (schaltungenRow.n > 0) reasons.push(`${schaltungenRow.n} Schaltung(en)`);
+ return res.status(400).json({
+ error: `Anbieter kann nicht gelöscht werden: ${reasons.join(', ')} verwenden diesen Anbieter`,
+ });
+ }
+
+ const deletedRules = await db.run('DELETE FROM anbieter_provisionsregeln WHERE anbieter_id = ?', [id]);
+ const result = await db.run('DELETE FROM anbieter WHERE id = ?', [id]);
+ if (result.changes === 0) {
+ return res.status(404).json({ error: 'Anbieter nicht gefunden' });
+ }
+ let message = 'Anbieter erfolgreich gelöscht';
+ if (deletedRules.changes > 0) {
+ message += ` (${deletedRules.changes} Provisionsregel(n) wurden mit gelöscht)`;
+ }
+ res.json({ message });
+}));
+
+// Global commission rules for this provider
+router.get('/:id/provisionsregeln', asyncHandler(async (req, res) => {
+ const { id } = req.params;
+ const rows = await db.all(
+ `SELECT apr.*, a.name AS anbieter_name
+ FROM anbieter_provisionsregeln apr
+ JOIN anbieter a ON apr.anbieter_id = a.id
+ WHERE apr.anbieter_id = ? AND apr.aktiv = 1
+ ORDER BY apr.gueltig_ab DESC`,
+ [id]
+ );
+ res.json(rows);
+}));
+
+// Mitarbeiter-Prozentsätze for this provider
+router.get('/:id/mitarbeiter-prozentsaetze', asyncHandler(async (req, res) => {
+ const { id } = req.params;
+ const rows = await db.all(
+ 'SELECT * FROM mitarbeiter_prozentsaetze WHERE anbieter_id = ? ORDER BY gueltig_ab DESC',
+ [id]
+ );
+ res.json(rows);
+}));
+
+module.exports = router;
diff --git a/routes/auth.js b/routes/auth.js
new file mode 100644
index 0000000..8f7b79c
--- /dev/null
+++ b/routes/auth.js
@@ -0,0 +1,120 @@
+const express = require('express');
+const bcrypt = require('bcryptjs');
+const rateLimit = require('express-rate-limit');
+const db = require('../db');
+const asyncHandler = require('../middleware/asyncHandler');
+const { requireLogin } = require('../middleware/auth');
+
+const router = express.Router();
+
+const loginLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000,
+ max: 20,
+ message: { error: 'Zu viele Anmeldeversuche, bitte später erneut versuchen.' },
+ standardHeaders: true,
+ legacyHeaders: false,
+});
+
+// Whether the system needs initial setup (no users yet).
+router.get('/setup-required', asyncHandler(async (req, res) => {
+ const row = await db.get('SELECT COUNT(*) AS n FROM users');
+ res.json({ setupRequired: row.n === 0 });
+}));
+
+// One-shot setup: create the first admin user. Only works when users table is empty.
+router.post('/setup', asyncHandler(async (req, res) => {
+ const existing = await db.get('SELECT COUNT(*) AS n FROM users');
+ if (existing.n > 0) {
+ return res.status(403).json({ error: 'Setup bereits abgeschlossen' });
+ }
+ const { username, password, displayName } = req.body || {};
+ if (!username || !password) {
+ return res.status(400).json({ error: 'Username und Passwort erforderlich' });
+ }
+ if (password.length < 8) {
+ return res.status(400).json({ error: 'Passwort muss mindestens 8 Zeichen lang sein' });
+ }
+ const hash = await bcrypt.hash(password, 10);
+ const result = await db.run(
+ 'INSERT INTO users (username, password_hash, display_name, is_admin) VALUES (?, ?, ?, 1)',
+ [username, hash, displayName || username]
+ );
+ req.session.userId = result.lastID;
+ req.session.username = username;
+ req.session.isAdmin = true;
+ res.json({ id: result.lastID, username, isAdmin: true });
+}));
+
+router.post('/login', loginLimiter, asyncHandler(async (req, res) => {
+ const { username, password } = req.body || {};
+ if (!username || !password) {
+ return res.status(400).json({ error: 'Username und Passwort erforderlich' });
+ }
+ const user = await db.get(
+ 'SELECT id, username, password_hash, display_name, is_admin FROM users WHERE username = ?',
+ [username]
+ );
+ if (!user) {
+ return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
+ }
+ const ok = await bcrypt.compare(password, user.password_hash);
+ if (!ok) {
+ return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
+ }
+ req.session.regenerate((err) => {
+ if (err) return res.status(500).json({ error: 'Session-Fehler' });
+ req.session.userId = user.id;
+ req.session.username = user.username;
+ req.session.isAdmin = !!user.is_admin;
+ res.json({
+ id: user.id,
+ username: user.username,
+ displayName: user.display_name,
+ isAdmin: !!user.is_admin,
+ });
+ });
+}));
+
+router.post('/logout', (req, res) => {
+ if (!req.session) return res.json({ ok: true });
+ req.session.destroy(() => {
+ res.clearCookie('connect.sid');
+ res.json({ ok: true });
+ });
+});
+
+router.get('/me', requireLogin, asyncHandler(async (req, res) => {
+ const user = await db.get(
+ 'SELECT id, username, display_name, is_admin FROM users WHERE id = ?',
+ [req.session.userId]
+ );
+ if (!user) {
+ req.session.destroy(() => {});
+ return res.status(401).json({ error: 'Benutzer nicht gefunden' });
+ }
+ res.json({
+ id: user.id,
+ username: user.username,
+ displayName: user.display_name,
+ isAdmin: !!user.is_admin,
+ });
+}));
+
+router.post('/change-password', requireLogin, asyncHandler(async (req, res) => {
+ const { oldPassword, newPassword } = req.body || {};
+ if (!oldPassword || !newPassword) {
+ return res.status(400).json({ error: 'Altes und neues Passwort erforderlich' });
+ }
+ if (newPassword.length < 8) {
+ return res.status(400).json({ error: 'Neues Passwort muss mindestens 8 Zeichen lang sein' });
+ }
+ const user = await db.get('SELECT password_hash FROM users WHERE id = ?', [req.session.userId]);
+ if (!user) return res.status(401).json({ error: 'Benutzer nicht gefunden' });
+ const ok = await bcrypt.compare(oldPassword, user.password_hash);
+ if (!ok) return res.status(401).json({ error: 'Altes Passwort falsch' });
+ const newHash = await bcrypt.hash(newPassword, 10);
+ await db.run('UPDATE users SET password_hash = ? WHERE id = ?', [newHash, req.session.userId]);
+ res.json({ ok: true });
+}));
+
+module.exports = router;
diff --git a/routes/dokumente.js b/routes/dokumente.js
new file mode 100644
index 0000000..b6bb25f
--- /dev/null
+++ b/routes/dokumente.js
@@ -0,0 +1,105 @@
+const express = require('express');
+const fs = require('fs');
+const path = require('path');
+const multer = require('multer');
+const db = require('../db');
+const asyncHandler = require('../middleware/asyncHandler');
+
+const router = express.Router();
+
+const ALLOWED_FILE_TYPES = {
+ 'application/pdf': ['.pdf'],
+ 'image/jpeg': ['.jpg', '.jpeg'],
+ 'image/png': ['.png'],
+ 'application/msword': ['.doc'],
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
+};
+
+function fileFilter(req, file, cb) {
+ const allowedExtensions = Object.values(ALLOWED_FILE_TYPES).flat();
+ const ext = path.extname(file.originalname).toLowerCase();
+ if (allowedExtensions.includes(ext) && ALLOWED_FILE_TYPES[file.mimetype]) {
+ cb(null, true);
+ } else {
+ cb(new Error(`Dateityp nicht erlaubt: ${ext}. Erlaubt sind: PDF, JPG, PNG, DOC, DOCX`), false);
+ }
+}
+
+const storage = multer.diskStorage({
+ destination: (req, file, cb) => cb(null, 'uploads/'),
+ filename: (req, file, cb) => {
+ const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
+ cb(null, uniqueSuffix + path.extname(file.originalname));
+ },
+});
+
+const upload = multer({ storage, fileFilter, limits: { fileSize: 10 * 1024 * 1024 } });
+
+// Upload document for a schaltung (mounted as /api/schaltungen/:id/dokumente by server.js)
+router.post('/schaltungen/:id/dokumente', upload.single('document'), asyncHandler(async (req, res) => {
+ const schaltung_id = req.params.id;
+ const file = req.file;
+ if (!file) return res.status(400).json({ error: 'Keine Datei hochgeladen' });
+
+ const cleanup = () => {
+ fs.unlink(file.path, (e) => {
+ if (e) console.error('Cleanup error:', e.message);
+ });
+ };
+
+ const schaltung = await db.get('SELECT id FROM schaltungen WHERE id = ?', [schaltung_id]);
+ if (!schaltung) {
+ cleanup();
+ return res.status(404).json({ error: 'Schaltung nicht gefunden' });
+ }
+
+ try {
+ const result = await db.run(
+ `INSERT INTO dokumente (schaltung_id, filename, original_filename, filepath, filesize, mimetype)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ [schaltung_id, file.filename, file.originalname, file.path, file.size, file.mimetype]
+ );
+ res.json({ id: result.lastID, message: 'Dokument erfolgreich hochgeladen', filename: file.originalname });
+ } catch (err) {
+ cleanup();
+ throw err;
+ }
+}));
+
+// List documents for a schaltung
+router.get('/schaltungen/:id/dokumente', asyncHandler(async (req, res) => {
+ const rows = await db.all('SELECT * FROM dokumente WHERE schaltung_id = ?', [req.params.id]);
+ res.json(rows);
+}));
+
+// Download single document
+router.get('/dokumente/:id/download', asyncHandler(async (req, res) => {
+ const row = await db.get('SELECT * FROM dokumente WHERE id = ?', [req.params.id]);
+ if (!row) return res.status(404).json({ error: 'Dokument nicht gefunden' });
+ const filepath = req.app.locals.safeUploadPath(row.filepath);
+ if (!filepath) {
+ console.error(`Path traversal attempt or invalid path for document ${row.id}: ${row.filepath}`);
+ return res.status(400).json({ error: 'Ungültiger Dateipfad' });
+ }
+ res.download(filepath, row.original_filename);
+}));
+
+// Delete document
+router.delete('/dokumente/:id', asyncHandler(async (req, res) => {
+ const row = await db.get('SELECT * FROM dokumente WHERE id = ?', [req.params.id]);
+ if (!row) return res.status(404).json({ error: 'Dokument nicht gefunden' });
+
+ await db.run('DELETE FROM dokumente WHERE id = ?', [req.params.id]);
+
+ const filepath = req.app.locals.safeUploadPath(row.filepath);
+ if (filepath) {
+ fs.unlink(filepath, (err) => {
+ if (err) console.error('Fehler beim Löschen der Datei:', err.message);
+ });
+ } else {
+ console.error(`Skipping file delete - invalid path: ${row.filepath}`);
+ }
+ res.json({ message: 'Dokument erfolgreich gelöscht' });
+}));
+
+module.exports = router;
diff --git a/routes/mitarbeiter.js b/routes/mitarbeiter.js
new file mode 100644
index 0000000..7f73d63
--- /dev/null
+++ b/routes/mitarbeiter.js
@@ -0,0 +1,34 @@
+const express = require('express');
+const db = require('../db');
+const asyncHandler = require('../middleware/asyncHandler');
+
+const router = express.Router();
+
+router.post('/', asyncHandler(async (req, res) => {
+ const { anbieter_id, prozentsatz, gueltig_ab, beschreibung } = req.body || {};
+ if (!anbieter_id) return res.status(400).json({ error: 'Anbieter ist erforderlich' });
+ const p = parseFloat(prozentsatz);
+ if (!Number.isFinite(p) || p < 0 || p > 100) {
+ return res.status(400).json({ error: 'Prozentsatz muss zwischen 0 und 100 liegen' });
+ }
+ try {
+ const result = await db.run(
+ 'INSERT INTO mitarbeiter_prozentsaetze (anbieter_id, prozentsatz, gueltig_ab, beschreibung) VALUES (?, ?, ?, ?)',
+ [anbieter_id, p, gueltig_ab || new Date().toISOString().split('T')[0], beschreibung || null]
+ );
+ res.json({ id: result.lastID, message: 'Mitarbeiter-Prozentsatz erfolgreich erstellt' });
+ } catch (err) {
+ if (/UNIQUE/.test(err.message)) {
+ return res.status(400).json({ error: 'Ein Prozentsatz für dieses Datum existiert bereits für diesen Anbieter' });
+ }
+ throw err;
+ }
+}));
+
+router.delete('/:id', asyncHandler(async (req, res) => {
+ const result = await db.run('DELETE FROM mitarbeiter_prozentsaetze WHERE id = ?', [req.params.id]);
+ if (result.changes === 0) return res.status(404).json({ error: 'Mitarbeiter-Prozentsatz nicht gefunden' });
+ res.json({ message: 'Mitarbeiter-Prozentsatz erfolgreich gelöscht' });
+}));
+
+module.exports = router;
diff --git a/routes/provisionen.js b/routes/provisionen.js
new file mode 100644
index 0000000..64f5867
--- /dev/null
+++ b/routes/provisionen.js
@@ -0,0 +1,151 @@
+const express = require('express');
+const db = require('../db');
+const asyncHandler = require('../middleware/asyncHandler');
+const provisionService = require('../provisionService');
+
+const router = express.Router();
+
+// Load all inputs needed by provisionService.calculate for one schaltung.
+async function loadProvisionContext(schaltungId) {
+ const schaltung = await db.get(
+ `SELECT s.*,
+ t.name AS tarif_name, t.provision_euro AS tarif_provision_euro,
+ t.mitarbeiter_provision AS tarif_mitarbeiter_provision,
+ t.beschreibung AS tarif_beschreibung,
+ a.name AS anbieter_name
+ FROM schaltungen s
+ JOIN tarife t ON s.tarif_id = t.id
+ JOIN anbieter a ON s.anbieter_id = a.id
+ WHERE s.id = ?`,
+ [schaltungId]
+ );
+ if (!schaltung) return null;
+
+ const tarif = {
+ id: schaltung.tarif_id,
+ name: schaltung.tarif_name,
+ provision_euro: schaltung.tarif_provision_euro,
+ mitarbeiter_provision: schaltung.tarif_mitarbeiter_provision,
+ beschreibung: schaltung.tarif_beschreibung,
+ };
+
+ const tarifRule = await db.get(
+ `SELECT * FROM provisionsregeln
+ WHERE tarif_id = ? AND aktiv = 1 AND gueltig_ab <= ?
+ ORDER BY gueltig_ab DESC LIMIT 1`,
+ [schaltung.tarif_id, schaltung.schaltungs_datum]
+ );
+
+ const anbieterRule = await db.get(
+ `SELECT * FROM anbieter_provisionsregeln
+ WHERE anbieter_id = ? AND aktiv = 1 AND gueltig_ab <= ?
+ ORDER BY gueltig_ab DESC LIMIT 1`,
+ [schaltung.anbieter_id, schaltung.schaltungs_datum]
+ );
+
+ const tarifoptionen = await db.all(
+ `SELECT t.*
+ FROM tarifoptionen t
+ JOIN schaltungen_tarifoptionen st ON t.id = st.tarifoption_id
+ WHERE st.schaltung_id = ?`,
+ [schaltungId]
+ );
+
+ const mp = await db.get(
+ `SELECT prozentsatz FROM mitarbeiter_prozentsaetze
+ WHERE anbieter_id = ? AND gueltig_ab <= ?
+ ORDER BY gueltig_ab DESC LIMIT 1`,
+ [schaltung.anbieter_id, schaltung.schaltungs_datum]
+ );
+
+ return {
+ schaltung,
+ tarif,
+ tarifRule: tarifRule || null,
+ anbieterRule: anbieterRule || null,
+ tarifoptionen,
+ mitarbeiterProzentsatz: mp ? mp.prozentsatz : 0,
+ };
+}
+
+// Provision for a single schaltung
+router.get('/schaltungen/:id/provision', asyncHandler(async (req, res) => {
+ const ctx = await loadProvisionContext(req.params.id);
+ if (!ctx) return res.status(404).json({ error: 'Schaltung nicht gefunden' });
+
+ const result = provisionService.calculate(ctx);
+ res.json({
+ id: ctx.schaltung.id,
+ schaltungs_id: ctx.schaltung.schaltungs_id,
+ schaltungs_datum: ctx.schaltung.schaltungs_datum,
+ vorname: ctx.schaltung.vorname,
+ nachname: ctx.schaltung.nachname,
+ firma: ctx.schaltung.firma,
+ tarif_name: ctx.tarif.name,
+ anbieter_name: ctx.schaltung.anbieter_name,
+ shop_provision: result.shop,
+ mitarbeiter_provision: result.mitarbeiter,
+ tarifoptionen: result.tarifoptionen,
+ hardware_gewinn: result.hardware_gewinn,
+ // Legacy flat fields for older frontend code
+ berechnete_shop_provision: result.shop.betrag.toFixed(2),
+ berechnete_mitarbeiter_provision: result.mitarbeiter.betrag.toFixed(2),
+ shop_quelle: result.shop.quelle,
+ mitarbeiter_quelle: result.mitarbeiter.quelle,
+ });
+}));
+
+// Aggregated commission overview across all schaltungen
+router.get('/provisionen', asyncHandler(async (req, res) => {
+ const schaltungen = await db.all('SELECT id FROM schaltungen ORDER BY schaltungs_datum DESC');
+
+ const processed = [];
+ let totalShop = 0;
+ let totalMitarbeiter = 0;
+
+ for (const { id } of schaltungen) {
+ const ctx = await loadProvisionContext(id);
+ if (!ctx) continue;
+ const result = provisionService.calculate(ctx);
+ totalShop += result.shop.betrag;
+ totalMitarbeiter += result.mitarbeiter.betrag;
+ processed.push({
+ id: ctx.schaltung.id,
+ schaltungs_id: ctx.schaltung.schaltungs_id,
+ eingabe_datum: ctx.schaltung.eingabe_datum,
+ schaltungs_datum: ctx.schaltung.schaltungs_datum,
+ vorname: ctx.schaltung.vorname,
+ nachname: ctx.schaltung.nachname,
+ firma: ctx.schaltung.firma,
+ hardware: ctx.schaltung.hardware,
+ hardware_ek: ctx.schaltung.hardware_ek,
+ kundenverkaufspreis_netto: ctx.schaltung.kundenverkaufspreis_netto,
+ tarif_name: ctx.tarif.name,
+ anbieter_name: ctx.schaltung.anbieter_name,
+ shop_provision_euro: result.shop.betrag,
+ berechnete_shop_provision: result.shop.betrag.toFixed(2),
+ mitarbeiter_prozentsatz: result.mitarbeiter.prozentsatz,
+ berechnete_mitarbeiter_provision: result.mitarbeiter.betrag.toFixed(2),
+ shop_quelle: result.shop.quelle,
+ mitarbeiter_quelle: result.mitarbeiter.quelle,
+ tarifoptionen_provision: result.tarifoptionen.betrag,
+ hardware_gewinn: result.hardware_gewinn,
+ });
+ }
+
+ res.json({
+ schaltungen: processed,
+ total_provision: totalShop.toFixed(2),
+ total_shop_provision: totalShop.toFixed(2),
+ total_mitarbeiter_provision: totalMitarbeiter.toFixed(2),
+ total_gesamt_provision: (totalShop + totalMitarbeiter).toFixed(2),
+ });
+}));
+
+// Deleted schaltungen list
+router.get('/geloeschte-schaltungen', asyncHandler(async (req, res) => {
+ const rows = await db.all('SELECT * FROM geloeschte_schaltungen ORDER BY geloescht_am DESC');
+ res.json(rows);
+}));
+
+module.exports = router;
diff --git a/routes/provisionsregeln.js b/routes/provisionsregeln.js
new file mode 100644
index 0000000..69ce563
--- /dev/null
+++ b/routes/provisionsregeln.js
@@ -0,0 +1,172 @@
+const express = require('express');
+const db = require('../db');
+const asyncHandler = require('../middleware/asyncHandler');
+
+const router = express.Router();
+
+// Validates that a percent rule has a basis_euro >= 0. Returns error string or null.
+function validateRule({ provision_typ, betrag, basis_euro, verkaeufer_provision_typ, verkaeufer_provision_betrag }) {
+ if (!['fix', 'prozent'].includes(provision_typ)) {
+ return 'Ungültiger Provisionstyp';
+ }
+ const b = parseFloat(betrag);
+ if (!Number.isFinite(b)) return 'Betrag muss eine Zahl sein';
+ if (provision_typ === 'prozent' && (b < 0 || b > 100)) {
+ return 'Prozentual muss zwischen 0 und 100 sein';
+ }
+ if (provision_typ === 'fix' && b < 0) {
+ return 'Fixer Betrag kann nicht negativ sein';
+ }
+ if (provision_typ === 'prozent') {
+ if (basis_euro === undefined || basis_euro === null || basis_euro === '') {
+ return 'Prozent-Provision benötigt einen Basis-Betrag (basis_euro)';
+ }
+ const bb = parseFloat(basis_euro);
+ if (!Number.isFinite(bb) || bb < 0) {
+ return 'Basis-Betrag muss eine positive Zahl sein';
+ }
+ }
+ if (verkaeufer_provision_typ) {
+ if (!['fix', 'prozent'].includes(verkaeufer_provision_typ)) {
+ return 'Ungültiger Verkäufer-Provisionstyp';
+ }
+ const vb = parseFloat(verkaeufer_provision_betrag);
+ if (!Number.isFinite(vb)) return 'Verkäufer-Betrag muss eine Zahl sein';
+ if (verkaeufer_provision_typ === 'prozent' && (vb < 0 || vb > 100)) {
+ return 'Verkäufer-Prozentwert muss zwischen 0 und 100 sein';
+ }
+ }
+ return null;
+}
+
+// --- Tariff-specific rules (provisionsregeln) ---
+
+router.post('/provisionsregeln', asyncHandler(async (req, res) => {
+ const { tarif_id, provision_typ, betrag, basis_euro, beschreibung, gueltig_ab,
+ verkaeufer_provision_typ, verkaeufer_provision_betrag, verkaeufer_provision_beschreibung } = req.body || {};
+ if (!tarif_id) return res.status(400).json({ error: 'Tarif ist erforderlich' });
+ const err = validateRule({ provision_typ, betrag, basis_euro, verkaeufer_provision_typ, verkaeufer_provision_betrag });
+ if (err) return res.status(400).json({ error: err });
+
+ const result = await db.run(
+ `INSERT INTO provisionsregeln
+ (tarif_id, provision_typ, betrag, basis_euro, beschreibung, gueltig_ab,
+ verkaeufer_provision_typ, verkaeufer_provision_betrag, verkaeufer_provision_beschreibung)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ tarif_id, provision_typ, betrag,
+ provision_typ === 'prozent' ? parseFloat(basis_euro) : null,
+ beschreibung || null,
+ gueltig_ab || new Date().toISOString().split('T')[0],
+ verkaeufer_provision_typ || null,
+ verkaeufer_provision_betrag != null ? parseFloat(verkaeufer_provision_betrag) : null,
+ verkaeufer_provision_beschreibung || null,
+ ]
+ );
+ res.json({ id: result.lastID, message: 'Provisionsregel erfolgreich erstellt' });
+}));
+
+router.put('/provisionsregeln/:id', asyncHandler(async (req, res) => {
+ const { id } = req.params;
+ const current = await db.get('SELECT * FROM provisionsregeln WHERE id = ?', [id]);
+ if (!current) return res.status(404).json({ error: 'Provisionsregel nicht gefunden' });
+
+ const merged = {
+ provision_typ: req.body.provision_typ !== undefined ? req.body.provision_typ : current.provision_typ,
+ betrag: req.body.betrag !== undefined ? req.body.betrag : current.betrag,
+ basis_euro: req.body.basis_euro !== undefined ? req.body.basis_euro : current.basis_euro,
+ beschreibung: req.body.beschreibung !== undefined ? req.body.beschreibung : current.beschreibung,
+ gueltig_ab: req.body.gueltig_ab !== undefined ? req.body.gueltig_ab : current.gueltig_ab,
+ aktiv: req.body.aktiv !== undefined ? req.body.aktiv : current.aktiv,
+ verkaeufer_provision_typ: req.body.verkaeufer_provision_typ !== undefined ? req.body.verkaeufer_provision_typ : current.verkaeufer_provision_typ,
+ verkaeufer_provision_betrag: req.body.verkaeufer_provision_betrag !== undefined ? req.body.verkaeufer_provision_betrag : current.verkaeufer_provision_betrag,
+ verkaeufer_provision_beschreibung: req.body.verkaeufer_provision_beschreibung !== undefined ? req.body.verkaeufer_provision_beschreibung : current.verkaeufer_provision_beschreibung,
+ };
+
+ const verr = validateRule(merged);
+ if (verr) return res.status(400).json({ error: verr });
+
+ await db.run(
+ `UPDATE provisionsregeln
+ SET provision_typ = ?, betrag = ?, basis_euro = ?, beschreibung = ?, gueltig_ab = ?, aktiv = ?,
+ verkaeufer_provision_typ = ?, verkaeufer_provision_betrag = ?, verkaeufer_provision_beschreibung = ?
+ WHERE id = ?`,
+ [
+ merged.provision_typ, merged.betrag,
+ merged.provision_typ === 'prozent' ? merged.basis_euro : null,
+ merged.beschreibung, merged.gueltig_ab, merged.aktiv,
+ merged.verkaeufer_provision_typ,
+ merged.verkaeufer_provision_betrag,
+ merged.verkaeufer_provision_beschreibung,
+ id,
+ ]
+ );
+ res.json({ message: 'Provisionsregel erfolgreich aktualisiert' });
+}));
+
+// --- Global provider rules (anbieter_provisionsregeln) ---
+
+router.post('/anbieter-provisionsregeln', asyncHandler(async (req, res) => {
+ const { anbieter_id, provision_typ, betrag, basis_euro, beschreibung, gueltig_ab,
+ verkaeufer_provision_typ, verkaeufer_provision_betrag, verkaeufer_provision_beschreibung } = req.body || {};
+ if (!anbieter_id) return res.status(400).json({ error: 'Anbieter ist erforderlich' });
+ const err = validateRule({ provision_typ, betrag, basis_euro, verkaeufer_provision_typ, verkaeufer_provision_betrag });
+ if (err) return res.status(400).json({ error: err });
+
+ const result = await db.run(
+ `INSERT INTO anbieter_provisionsregeln
+ (anbieter_id, provision_typ, betrag, basis_euro, beschreibung, gueltig_ab,
+ verkaeufer_provision_typ, verkaeufer_provision_betrag, verkaeufer_provision_beschreibung)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ anbieter_id, provision_typ, betrag,
+ provision_typ === 'prozent' ? parseFloat(basis_euro) : null,
+ beschreibung || null,
+ gueltig_ab || new Date().toISOString().split('T')[0],
+ verkaeufer_provision_typ || null,
+ verkaeufer_provision_betrag != null ? parseFloat(verkaeufer_provision_betrag) : null,
+ verkaeufer_provision_beschreibung || null,
+ ]
+ );
+ res.json({ id: result.lastID, message: 'Globale Provisionsregel erfolgreich erstellt' });
+}));
+
+router.put('/anbieter-provisionsregeln/:id', asyncHandler(async (req, res) => {
+ const { id } = req.params;
+ const current = await db.get('SELECT * FROM anbieter_provisionsregeln WHERE id = ?', [id]);
+ if (!current) return res.status(404).json({ error: 'Globale Provisionsregel nicht gefunden' });
+
+ const merged = {
+ provision_typ: req.body.provision_typ !== undefined ? req.body.provision_typ : current.provision_typ,
+ betrag: req.body.betrag !== undefined ? req.body.betrag : current.betrag,
+ basis_euro: req.body.basis_euro !== undefined ? req.body.basis_euro : current.basis_euro,
+ beschreibung: req.body.beschreibung !== undefined ? req.body.beschreibung : current.beschreibung,
+ gueltig_ab: req.body.gueltig_ab !== undefined ? req.body.gueltig_ab : current.gueltig_ab,
+ aktiv: req.body.aktiv !== undefined ? req.body.aktiv : current.aktiv,
+ verkaeufer_provision_typ: req.body.verkaeufer_provision_typ !== undefined ? req.body.verkaeufer_provision_typ : current.verkaeufer_provision_typ,
+ verkaeufer_provision_betrag: req.body.verkaeufer_provision_betrag !== undefined ? req.body.verkaeufer_provision_betrag : current.verkaeufer_provision_betrag,
+ verkaeufer_provision_beschreibung: req.body.verkaeufer_provision_beschreibung !== undefined ? req.body.verkaeufer_provision_beschreibung : current.verkaeufer_provision_beschreibung,
+ };
+
+ const verr = validateRule(merged);
+ if (verr) return res.status(400).json({ error: verr });
+
+ await db.run(
+ `UPDATE anbieter_provisionsregeln
+ SET provision_typ = ?, betrag = ?, basis_euro = ?, beschreibung = ?, gueltig_ab = ?, aktiv = ?,
+ verkaeufer_provision_typ = ?, verkaeufer_provision_betrag = ?, verkaeufer_provision_beschreibung = ?
+ WHERE id = ?`,
+ [
+ merged.provision_typ, merged.betrag,
+ merged.provision_typ === 'prozent' ? merged.basis_euro : null,
+ merged.beschreibung, merged.gueltig_ab, merged.aktiv,
+ merged.verkaeufer_provision_typ,
+ merged.verkaeufer_provision_betrag,
+ merged.verkaeufer_provision_beschreibung,
+ id,
+ ]
+ );
+ res.json({ message: 'Globale Provisionsregel erfolgreich aktualisiert' });
+}));
+
+module.exports = router;
diff --git a/routes/schaltungen.js b/routes/schaltungen.js
new file mode 100644
index 0000000..9b411bb
--- /dev/null
+++ b/routes/schaltungen.js
@@ -0,0 +1,217 @@
+const express = require('express');
+const db = require('../db');
+const asyncHandler = require('../middleware/asyncHandler');
+
+const router = express.Router();
+
+function validateDate(s) {
+ return typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s) && !isNaN(new Date(s).getTime());
+}
+
+function validateHardware(h) {
+ return !h || String(h).length === 15;
+}
+
+function validateAmount(a) {
+ return a === null || a === undefined || (Number.isFinite(parseFloat(a)) && parseFloat(a) >= 0);
+}
+
+function validateSchaltungBody(body) {
+ const { schaltungs_id, eingabe_datum, schaltungs_datum, vorname, nachname,
+ rufnummer, anbieter_id, tarif_id, hardware, hardware_ek, kundenverkaufspreis_netto } = body || {};
+ if (!schaltungs_id || !schaltungs_id.trim()) return 'Schaltungs-ID ist erforderlich';
+ if (!validateDate(eingabe_datum)) return 'Ungültiges Eingabe-Datum (Format: YYYY-MM-DD)';
+ if (!validateDate(schaltungs_datum)) return 'Ungültiges Schaltungs-Datum (Format: YYYY-MM-DD)';
+ if (!vorname || !vorname.trim()) return 'Vorname ist erforderlich';
+ if (!nachname || !nachname.trim()) return 'Nachname ist erforderlich';
+ if (!rufnummer || !rufnummer.trim()) return 'Rufnummer ist erforderlich';
+ if (!anbieter_id) return 'Anbieter ist erforderlich';
+ if (!tarif_id) return 'Tarif ist erforderlich';
+ if (!validateHardware(hardware)) return 'Hardware muss genau 15 Zeichen lang sein';
+ if (hardware && hardware.trim() !== '') {
+ if (!validateAmount(hardware_ek)) return 'Hardware-EK muss eine positive Zahl sein';
+ if (!validateAmount(kundenverkaufspreis_netto)) return 'Kundenverkaufspreis muss eine positive Zahl sein';
+ }
+ return null;
+}
+
+router.get('/', asyncHandler(async (req, res) => {
+ const rows = await db.all(
+ `SELECT s.*, a.name AS anbieter_name, t.name AS tarif_name
+ FROM schaltungen s
+ JOIN anbieter a ON s.anbieter_id = a.id
+ JOIN tarife t ON s.tarif_id = t.id
+ ORDER BY s.created_at DESC`
+ );
+ res.json(rows);
+}));
+
+router.post('/', asyncHandler(async (req, res) => {
+ const err = validateSchaltungBody(req.body);
+ if (err) return res.status(400).json({ error: err });
+
+ const b = req.body;
+ try {
+ const result = await db.run(
+ `INSERT INTO schaltungen
+ (schaltungs_id, eingabe_datum, schaltungs_datum, vorname, nachname, firma, rufnummer,
+ anbieter_id, tarif_id, hardware, hardware_ek, kundenverkaufspreis_netto)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ b.schaltungs_id, b.eingabe_datum, b.schaltungs_datum, b.vorname, b.nachname,
+ b.firma || null, b.rufnummer, b.anbieter_id, b.tarif_id, b.hardware || null,
+ b.hardware_ek || null, b.kundenverkaufspreis_netto || null,
+ ]
+ );
+ res.json({ id: result.lastID, message: 'Schaltung erfolgreich erstellt' });
+ } catch (e) {
+ if (/UNIQUE/.test(e.message)) {
+ return res.status(400).json({ error: 'Schaltungs-ID existiert bereits' });
+ }
+ throw e;
+ }
+}));
+
+router.put('/:id', asyncHandler(async (req, res) => {
+ const { id } = req.params;
+ const err = validateSchaltungBody(req.body);
+ if (err) return res.status(400).json({ error: err });
+
+ const b = req.body;
+ const tarifoptionen = Array.isArray(b.tarifoptionen) ? b.tarifoptionen : [];
+
+ await db.transaction(async () => {
+ const upd = await db.run(
+ `UPDATE schaltungen
+ SET schaltungs_id = ?, eingabe_datum = ?, schaltungs_datum = ?, vorname = ?, nachname = ?,
+ firma = ?, rufnummer = ?, anbieter_id = ?, tarif_id = ?, hardware = ?,
+ hardware_ek = ?, kundenverkaufspreis_netto = ?
+ WHERE id = ?`,
+ [
+ b.schaltungs_id, b.eingabe_datum, b.schaltungs_datum, b.vorname, b.nachname,
+ b.firma || null, b.rufnummer, b.anbieter_id, b.tarif_id, b.hardware || null,
+ b.hardware_ek || null, b.kundenverkaufspreis_netto || null, id,
+ ]
+ );
+ if (upd.changes === 0) {
+ const e = new Error('Schaltung nicht gefunden');
+ e.status = 404;
+ throw e;
+ }
+ await db.run('DELETE FROM schaltungen_tarifoptionen WHERE schaltung_id = ?', [id]);
+ for (const tarifoptionId of tarifoptionen) {
+ await db.run(
+ 'INSERT INTO schaltungen_tarifoptionen (schaltung_id, tarifoption_id) VALUES (?, ?)',
+ [id, tarifoptionId]
+ );
+ }
+ });
+ res.json({ message: 'Schaltung erfolgreich aktualisiert' });
+}));
+
+router.delete('/:id', asyncHandler(async (req, res) => {
+ const { id } = req.params;
+ const { loeschgrund } = req.body || {};
+ if (!loeschgrund || !loeschgrund.trim()) {
+ return res.status(400).json({ error: 'Löschgrund ist erforderlich' });
+ }
+
+ const schaltung = await db.get(
+ `SELECT s.*, a.name AS anbieter_name, t.name AS tarif_name
+ FROM schaltungen s
+ JOIN anbieter a ON s.anbieter_id = a.id
+ JOIN tarife t ON s.tarif_id = t.id
+ WHERE s.id = ?`,
+ [id]
+ );
+ if (!schaltung) return res.status(404).json({ error: 'Schaltung nicht gefunden' });
+
+ const documents = await db.all('SELECT filepath FROM dokumente WHERE schaltung_id = ?', [id]);
+ const { safeUploadPath } = req.app.locals;
+ const fs = require('fs');
+
+ await db.transaction(async () => {
+ await db.run(
+ `INSERT INTO geloeschte_schaltungen (
+ original_id, schaltungs_id, eingabe_datum, schaltungs_datum, vorname, nachname,
+ firma, rufnummer, anbieter_id, tarif_id, hardware, anbieter_name, tarif_name, loeschgrund, geloescht_von
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ schaltung.id, schaltung.schaltungs_id, schaltung.eingabe_datum, schaltung.schaltungs_datum,
+ schaltung.vorname, schaltung.nachname, schaltung.firma, schaltung.rufnummer,
+ schaltung.anbieter_id, schaltung.tarif_id, schaltung.hardware,
+ schaltung.anbieter_name, schaltung.tarif_name, loeschgrund.trim(),
+ req.session && req.session.username ? req.session.username : 'System',
+ ]
+ );
+ // Existing DBs may lack ON DELETE CASCADE on dokumente — delete explicitly.
+ await db.run('DELETE FROM dokumente WHERE schaltung_id = ?', [id]);
+ await db.run('DELETE FROM schaltungen WHERE id = ?', [id]);
+ });
+
+ // Best-effort file cleanup, outside the transaction.
+ let deletedFiles = 0;
+ for (const doc of documents) {
+ const filePath = safeUploadPath(doc.filepath);
+ if (!filePath) {
+ console.error(`Skipping file delete - invalid path: ${doc.filepath}`);
+ continue;
+ }
+ try {
+ fs.unlinkSync(filePath);
+ deletedFiles++;
+ } catch (e) {
+ console.error(`Error deleting ${filePath}:`, e.message);
+ }
+ }
+
+ res.json({
+ message: 'Schaltung erfolgreich gelöscht und archiviert',
+ loeschgrund: loeschgrund.trim(),
+ deleted_files: deletedFiles,
+ });
+}));
+
+// Tarifoptionen linked to a specific schaltung
+router.get('/:id/tarifoptionen', asyncHandler(async (req, res) => {
+ const { id } = req.params;
+ const rows = await db.all(
+ `SELECT t.*, st.id AS schaltung_tarifoption_id
+ FROM tarifoptionen t
+ JOIN schaltungen_tarifoptionen st ON t.id = st.tarifoption_id
+ WHERE st.schaltung_id = ?
+ ORDER BY t.name`,
+ [id]
+ );
+ res.json(rows);
+}));
+
+router.post('/:id/tarifoptionen', asyncHandler(async (req, res) => {
+ const schaltung_id = req.params.id;
+ const { tarifoption_id } = req.body || {};
+ if (!tarifoption_id) return res.status(400).json({ error: 'tarifoption_id ist erforderlich' });
+ try {
+ const result = await db.run(
+ 'INSERT INTO schaltungen_tarifoptionen (schaltung_id, tarifoption_id) VALUES (?, ?)',
+ [schaltung_id, tarifoption_id]
+ );
+ res.json({ id: result.lastID, message: 'Tarifoption erfolgreich hinzugefügt' });
+ } catch (err) {
+ if (/UNIQUE/.test(err.message)) {
+ return res.status(400).json({ error: 'Tarifoption bereits hinzugefügt' });
+ }
+ throw err;
+ }
+}));
+
+router.delete('/:schaltung_id/tarifoptionen/:tarifoption_id', asyncHandler(async (req, res) => {
+ const { schaltung_id, tarifoption_id } = req.params;
+ const result = await db.run(
+ 'DELETE FROM schaltungen_tarifoptionen WHERE schaltung_id = ? AND tarifoption_id = ?',
+ [schaltung_id, tarifoption_id]
+ );
+ if (result.changes === 0) return res.status(404).json({ error: 'Verknüpfung nicht gefunden' });
+ res.json({ message: 'Tarifoption erfolgreich entfernt' });
+}));
+
+module.exports = router;
diff --git a/routes/tarife.js b/routes/tarife.js
new file mode 100644
index 0000000..07c63f1
--- /dev/null
+++ b/routes/tarife.js
@@ -0,0 +1,123 @@
+const express = require('express');
+const db = require('../db');
+const asyncHandler = require('../middleware/asyncHandler');
+
+const router = express.Router();
+
+function validateAmount(amount) {
+ return amount === null || amount === undefined || (Number.isFinite(parseFloat(amount)) && parseFloat(amount) >= 0);
+}
+
+router.get('/', asyncHandler(async (req, res) => {
+ const rows = await db.all(
+ `SELECT t.*, a.name AS anbieter_name
+ FROM tarife t
+ JOIN anbieter a ON t.anbieter_id = a.id
+ ORDER BY a.name, t.name`
+ );
+ res.json(rows);
+}));
+
+router.get('/:anbieter_id', asyncHandler(async (req, res) => {
+ const { anbieter_id } = req.params;
+ const rows = await db.all('SELECT * FROM tarife WHERE anbieter_id = ? ORDER BY name', [anbieter_id]);
+ res.json(rows);
+}));
+
+router.post('/', asyncHandler(async (req, res) => {
+ const { name, anbieter_id, provision_euro, beschreibung } = req.body || {};
+
+ if (!name || !name.trim()) {
+ return res.status(400).json({ error: 'Tarifname ist erforderlich' });
+ }
+ if (!anbieter_id) {
+ return res.status(400).json({ error: 'Anbieter ist erforderlich' });
+ }
+ if (!validateAmount(provision_euro)) {
+ return res.status(400).json({ error: 'Provision muss eine positive Zahl sein' });
+ }
+
+ try {
+ const result = await db.run(
+ `INSERT INTO tarife (name, anbieter_id, provision_euro, beschreibung) VALUES (?, ?, ?, ?)`,
+ [name.trim(), anbieter_id, provision_euro || 0, beschreibung || null]
+ );
+ res.json({
+ id: result.lastID,
+ name: name.trim(),
+ anbieter_id,
+ provision_euro: provision_euro || 0,
+ beschreibung: beschreibung || null,
+ });
+ } catch (err) {
+ if (/UNIQUE/.test(err.message)) {
+ return res.status(400).json({ error: 'Tarif mit diesem Namen existiert bereits für diesen Anbieter' });
+ }
+ throw err;
+ }
+}));
+
+router.put('/:id', asyncHandler(async (req, res) => {
+ const { id } = req.params;
+ const { name, provision_euro, beschreibung } = req.body || {};
+ if (!name) return res.status(400).json({ error: 'Tarif-Name ist erforderlich' });
+ if (!validateAmount(provision_euro)) {
+ return res.status(400).json({ error: 'Provision muss eine positive Zahl sein' });
+ }
+ const result = await db.run(
+ `UPDATE tarife SET name = ?, provision_euro = ?, beschreibung = ? WHERE id = ?`,
+ [name, provision_euro || 0, beschreibung || null, id]
+ );
+ if (result.changes === 0) return res.status(404).json({ error: 'Tarif nicht gefunden' });
+ res.json({ message: 'Tarif erfolgreich aktualisiert' });
+}));
+
+router.delete('/:id', asyncHandler(async (req, res) => {
+ const { id } = req.params;
+ const used = await db.get(
+ 'SELECT EXISTS(SELECT 1 FROM schaltungen WHERE tarif_id = ?) AS has_schaltungen',
+ [id]
+ );
+ if (used.has_schaltungen) {
+ return res.status(400).json({ error: 'Tarif kann nicht gelöscht werden: Tarif wird in Schaltungen verwendet' });
+ }
+ const result = await db.run('DELETE FROM tarife WHERE id = ?', [id]);
+ if (result.changes === 0) return res.status(404).json({ error: 'Tarif nicht gefunden' });
+ res.json({ message: 'Tarif erfolgreich gelöscht' });
+}));
+
+router.get('/:tarif_id/tarifoptionen', asyncHandler(async (req, res) => {
+ const { tarif_id } = req.params;
+ const rows = await db.all(
+ 'SELECT * FROM tarifoptionen WHERE tarif_id = ? AND aktiv = 1 ORDER BY name',
+ [tarif_id]
+ );
+ res.json(rows);
+}));
+
+router.get('/:id/provisionsregeln', asyncHandler(async (req, res) => {
+ const { id } = req.params;
+ const rows = await db.all(
+ `SELECT pr.*, t.name AS tarif_name, a.name AS anbieter_name
+ FROM provisionsregeln pr
+ JOIN tarife t ON pr.tarif_id = t.id
+ JOIN anbieter a ON t.anbieter_id = a.id
+ WHERE pr.tarif_id = ? AND pr.aktiv = 1
+ ORDER BY pr.gueltig_ab DESC`,
+ [id]
+ );
+ res.json(rows);
+}));
+
+router.put('/:id/mitarbeiter-provision', asyncHandler(async (req, res) => {
+ const { id } = req.params;
+ const { mitarbeiter_provision } = req.body || {};
+ const result = await db.run(
+ 'UPDATE tarife SET mitarbeiter_provision = ? WHERE id = ?',
+ [mitarbeiter_provision || 0, id]
+ );
+ if (result.changes === 0) return res.status(404).json({ error: 'Tarif nicht gefunden' });
+ res.json({ message: 'Mitarbeiter-Provision erfolgreich aktualisiert' });
+}));
+
+module.exports = router;
diff --git a/routes/tarifoptionen.js b/routes/tarifoptionen.js
new file mode 100644
index 0000000..957443f
--- /dev/null
+++ b/routes/tarifoptionen.js
@@ -0,0 +1,205 @@
+const express = require('express');
+const db = require('../db');
+const asyncHandler = require('../middleware/asyncHandler');
+
+const router = express.Router();
+
+// Helper: find or insert a tarifoptionen row matching the new structure's option.
+// Used by the /multi endpoints to keep the old tarifoptionen table in sync.
+async function syncLegacyOption(anbieter_id, tarif_id, name, code, provision_euro, beschreibung) {
+ try {
+ await db.run(
+ `INSERT OR IGNORE INTO tarifoptionen (anbieter_id, tarif_id, name, code, provision_euro, beschreibung)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ [anbieter_id, tarif_id, name, code || null, provision_euro || 0, beschreibung || null]
+ );
+ } catch (err) {
+ console.error('Legacy tarifoption sync failed:', err.message);
+ }
+}
+
+// --- Legacy single-tariff API (per-(anbieter,tarif) options) ---
+
+router.get('/', asyncHandler(async (req, res) => {
+ const rows = await db.all(
+ `SELECT t.*, a.name AS anbieter_name, ta.name AS tarif_name
+ FROM tarifoptionen t
+ JOIN anbieter a ON t.anbieter_id = a.id
+ LEFT JOIN tarife ta ON t.tarif_id = ta.id
+ WHERE t.aktiv = 1
+ ORDER BY a.name, t.tarif_id IS NULL DESC, COALESCE(ta.name, ''), t.name`
+ );
+ res.json(rows);
+}));
+
+router.post('/', asyncHandler(async (req, res) => {
+ const { anbieter_id, tarif_id, name, code, provision_euro, beschreibung } = req.body || {};
+ if (!anbieter_id && !tarif_id) {
+ return res.status(400).json({ error: 'Entweder Anbieter oder Tarif muss angegeben werden' });
+ }
+ if (!name || !name.trim()) {
+ return res.status(400).json({ error: 'Name ist erforderlich' });
+ }
+
+ let resolvedAnbieterId = anbieter_id;
+ if (tarif_id && !resolvedAnbieterId) {
+ const tarif = await db.get('SELECT anbieter_id FROM tarife WHERE id = ?', [tarif_id]);
+ if (!tarif) return res.status(404).json({ error: 'Tarif nicht gefunden' });
+ resolvedAnbieterId = tarif.anbieter_id;
+ }
+
+ try {
+ const result = await db.run(
+ `INSERT INTO tarifoptionen (anbieter_id, tarif_id, name, code, provision_euro, beschreibung)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ [resolvedAnbieterId, tarif_id || null, name.trim(), code || null, provision_euro || 0, beschreibung || null]
+ );
+ res.json({ id: result.lastID, message: 'Tarifoption erfolgreich erstellt' });
+ } catch (err) {
+ if (/UNIQUE/.test(err.message)) {
+ const scope = tarif_id ? 'für diesen Tarif' : 'für diesen Anbieter';
+ return res.status(400).json({ error: `Eine Tarifoption mit diesem Namen existiert bereits ${scope}` });
+ }
+ throw err;
+ }
+}));
+
+router.put('/:id', asyncHandler(async (req, res) => {
+ const { id } = req.params;
+ const { name, provision_euro } = req.body || {};
+ if (!name) return res.status(400).json({ error: 'Tarifoption-Name ist erforderlich' });
+ const result = await db.run(
+ 'UPDATE tarifoptionen SET name = ?, provision_euro = ? WHERE id = ?',
+ [name, provision_euro || 0, id]
+ );
+ if (result.changes === 0) return res.status(404).json({ error: 'Tarifoption nicht gefunden' });
+ res.json({ message: 'Tarifoption erfolgreich aktualisiert' });
+}));
+
+router.delete('/:id', asyncHandler(async (req, res) => {
+ const { id } = req.params;
+ const result = await db.run('DELETE FROM tarifoptionen WHERE id = ?', [id]);
+ if (result.changes === 0) return res.status(404).json({ error: 'Tarifoption nicht gefunden' });
+ res.json({ message: 'Tarifoption erfolgreich gelöscht' });
+}));
+
+// --- Multi-tariff API (tarifoptionen_new + tarifoptionen_tarife) ---
+
+router.get('/multi', asyncHandler(async (req, res) => {
+ const rows = await db.all(
+ `SELECT tn.id, tn.name, tn.code, tn.provision_euro, tn.beschreibung, tn.aktiv, tn.created_at,
+ GROUP_CONCAT(t.name || ' (' || a.name || ')') AS tarif_names,
+ GROUP_CONCAT(t.id) AS tarif_ids
+ FROM tarifoptionen_new tn
+ LEFT JOIN tarifoptionen_tarife tt ON tn.id = tt.tarifoption_id
+ LEFT JOIN tarife t ON tt.tarif_id = t.id
+ LEFT JOIN anbieter a ON t.anbieter_id = a.id
+ WHERE tn.aktiv = 1
+ GROUP BY tn.id, tn.name, tn.code, tn.provision_euro, tn.beschreibung, tn.aktiv, tn.created_at
+ ORDER BY tn.created_at DESC`
+ );
+ res.json(rows);
+}));
+
+router.post('/multi', asyncHandler(async (req, res) => {
+ const { anbieter_id, tarif_ids = [], is_anbieter_weit = false, name, code, provision_euro, beschreibung } = req.body || {};
+ if (!anbieter_id || !name) return res.status(400).json({ error: 'Anbieter und Name sind erforderlich' });
+ if (!is_anbieter_weit && (!tarif_ids || tarif_ids.length === 0)) {
+ return res.status(400).json({ error: 'Bei tarif-spezifischen Optionen müssen Tarife ausgewählt werden' });
+ }
+
+ const newId = await db.transaction(async () => {
+ const inserted = await db.run(
+ 'INSERT INTO tarifoptionen_new (name, code, provision_euro, beschreibung) VALUES (?, ?, ?, ?)',
+ [name, code || null, provision_euro || 0, beschreibung || null]
+ );
+ const tarifoptionId = inserted.lastID;
+
+ let targetTarifIds = tarif_ids;
+ if (is_anbieter_weit) {
+ const tarifs = await db.all('SELECT id FROM tarife WHERE anbieter_id = ?', [anbieter_id]);
+ targetTarifIds = tarifs.map(t => t.id);
+ }
+
+ for (const tarifId of targetTarifIds) {
+ await db.run(
+ 'INSERT INTO tarifoptionen_tarife (tarifoption_id, tarif_id) VALUES (?, ?)',
+ [tarifoptionId, tarifId]
+ );
+ await syncLegacyOption(anbieter_id, tarifId, name, code, provision_euro, beschreibung);
+ }
+ return { id: tarifoptionId, count: targetTarifIds.length };
+ });
+
+ res.json({ id: newId.id, message: 'Tarifoption erfolgreich erstellt', assignments: newId.count });
+}));
+
+router.put('/multi/:id', asyncHandler(async (req, res) => {
+ const { id } = req.params;
+ const { name, code, provision_euro, beschreibung, anbieter_id, is_anbieter_weit, tarif_ids } = req.body || {};
+ if (!name) return res.status(400).json({ error: 'Tarifoption-Name ist erforderlich' });
+
+ await db.transaction(async () => {
+ const upd = await db.run(
+ 'UPDATE tarifoptionen_new SET name = ?, code = ?, provision_euro = ?, beschreibung = ? WHERE id = ?',
+ [name, code || null, provision_euro || 0, beschreibung || null, id]
+ );
+ if (upd.changes === 0) {
+ const e = new Error('Tarifoption nicht gefunden');
+ e.status = 404;
+ throw e;
+ }
+ await db.run('DELETE FROM tarifoptionen_tarife WHERE tarifoption_id = ?', [id]);
+ // Best-effort cleanup of legacy rows that may have been synced under this name/code.
+ await db.run(
+ 'DELETE FROM tarifoptionen WHERE name = ? AND (code = ? OR (code IS NULL AND ? IS NULL))',
+ [name, code || null, code || null]
+ );
+
+ let targetTarifIds = tarif_ids || [];
+ if (is_anbieter_weit && anbieter_id) {
+ const tarifs = await db.all('SELECT id FROM tarife WHERE anbieter_id = ?', [anbieter_id]);
+ targetTarifIds = tarifs.map(t => t.id);
+ }
+
+ for (const tarifId of targetTarifIds) {
+ await db.run(
+ 'INSERT INTO tarifoptionen_tarife (tarifoption_id, tarif_id) VALUES (?, ?)',
+ [id, tarifId]
+ );
+ const tarif = await db.get('SELECT anbieter_id FROM tarife WHERE id = ?', [tarifId]);
+ if (tarif) {
+ await syncLegacyOption(tarif.anbieter_id, tarifId, name, code, provision_euro, beschreibung);
+ }
+ }
+ });
+ res.json({ message: 'Tarifoption erfolgreich aktualisiert' });
+}));
+
+router.delete('/multi/:id', asyncHandler(async (req, res) => {
+ const { id } = req.params;
+ const option = await db.get('SELECT name, code FROM tarifoptionen_new WHERE id = ?', [id]);
+ if (!option) return res.status(404).json({ error: 'Tarifoption nicht gefunden' });
+
+ await db.transaction(async () => {
+ await db.run('DELETE FROM tarifoptionen_tarife WHERE tarifoption_id = ?', [id]);
+ await db.run(
+ 'DELETE FROM tarifoptionen WHERE name = ? AND (code = ? OR (code IS NULL AND ? IS NULL))',
+ [option.name, option.code, option.code]
+ );
+ await db.run('DELETE FROM tarifoptionen_new WHERE id = ?', [id]);
+ });
+ res.json({ message: 'Tarifoption erfolgreich gelöscht' });
+}));
+
+// Tariff-specific tarifoption list (used by frontend when adding options to a schaltung)
+router.get('/tarif/:tarif_id', asyncHandler(async (req, res) => {
+ const { tarif_id } = req.params;
+ const rows = await db.all(
+ 'SELECT * FROM tarifoptionen WHERE tarif_id = ? AND aktiv = 1 ORDER BY name',
+ [tarif_id]
+ );
+ res.json(rows);
+}));
+
+module.exports = router;
diff --git a/scripts/create-user.js b/scripts/create-user.js
new file mode 100644
index 0000000..f98acb0
--- /dev/null
+++ b/scripts/create-user.js
@@ -0,0 +1,56 @@
+#!/usr/bin/env node
+// CLI: create-user
[--admin]
+// Useful for adding additional users outside the web UI.
+
+const bcrypt = require('bcryptjs');
+const { initDatabase, getDatabase, closeDatabase } = require('../database');
+
+async function main() {
+ const args = process.argv.slice(2);
+ if (args.length < 2) {
+ console.error('Usage: npm run create-user -- [--admin]');
+ process.exit(1);
+ }
+ const username = args[0];
+ const password = args[1];
+ const isAdmin = args.includes('--admin');
+
+ if (password.length < 8) {
+ console.error('Passwort muss mindestens 8 Zeichen lang sein');
+ process.exit(1);
+ }
+
+ initDatabase();
+ // Give initDatabase a moment to run its serialized statements.
+ await new Promise(r => setTimeout(r, 200));
+
+ const db = getDatabase();
+ const hash = await bcrypt.hash(password, 10);
+
+ await new Promise((resolve, reject) => {
+ db.run(
+ 'INSERT INTO users (username, password_hash, display_name, is_admin) VALUES (?, ?, ?, ?)',
+ [username, hash, username, isAdmin ? 1 : 0],
+ function (err) {
+ if (err) reject(err);
+ else resolve(this.lastID);
+ }
+ );
+ }).then((id) => {
+ console.log(`User '${username}' angelegt (id=${id}, admin=${isAdmin}).`);
+ }).catch((err) => {
+ if (/UNIQUE/.test(err.message)) {
+ console.error(`User '${username}' existiert bereits.`);
+ } else {
+ console.error('Fehler:', err.message);
+ }
+ process.exitCode = 1;
+ });
+
+ closeDatabase();
+}
+
+main().catch(err => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/server.js b/server.js
index ab63d43..cef946c 100644
--- a/server.js
+++ b/server.js
@@ -1,2139 +1,169 @@
+// Load .env.local (gitignored) before anything else reads process.env.
+require('dotenv').config({ path: require('path').join(__dirname, '.env.local') });
+
const express = require('express');
const path = require('path');
const cors = require('cors');
-const multer = require('multer');
const fs = require('fs');
-const { initDatabase, getDatabase, closeDatabase } = require('./database');
+const helmet = require('helmet');
+const session = require('express-session');
+const SQLiteStore = require('connect-sqlite3')(session);
+const rateLimit = require('express-rate-limit');
+const multer = require('multer');
+
+const { initDatabase, closeDatabase } = require('./database');
+const { requireLogin } = require('./middleware/auth');
const app = express();
const PORT = process.env.PORT || 3000;
+const isProd = process.env.NODE_ENV === 'production';
-// Initialize database
initDatabase();
-// Graceful shutdown handler
+// Graceful shutdown
function gracefulShutdown(signal) {
console.log(`\n${signal} received, closing database connection...`);
closeDatabase();
process.exit(0);
}
-
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
-// Middleware
-app.use(cors());
-app.use(express.json());
-app.use(express.static('public'));
-
-// Create uploads directory if it doesn't exist
+// Uploads directory
const uploadsDir = path.join(__dirname, 'uploads');
-if (!fs.existsSync(uploadsDir)) {
- fs.mkdirSync(uploadsDir);
-}
+if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
-// Allowed file types for uploads
-const ALLOWED_FILE_TYPES = {
- 'application/pdf': ['.pdf'],
- 'image/jpeg': ['.jpg', '.jpeg'],
- 'image/png': ['.png'],
- 'application/msword': ['.doc'],
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx']
-};
-
-// File filter for multer
-const fileFilter = (req, file, cb) => {
- const allowedExtensions = Object.values(ALLOWED_FILE_TYPES).flat();
- const ext = path.extname(file.originalname).toLowerCase();
-
- if (allowedExtensions.includes(ext) && ALLOWED_FILE_TYPES[file.mimetype]) {
- cb(null, true);
- } else {
- cb(new Error(`Dateityp nicht erlaubt: ${ext}. Erlaubt sind: PDF, JPG, PNG, DOC, DOCX`), false);
+// Resolve a stored relative filepath safely under uploadsDir, or null if it escapes.
+function safeUploadPath(storedPath) {
+ const resolved = path.resolve(__dirname, storedPath);
+ const uploadsResolved = path.resolve(uploadsDir);
+ if (resolved !== uploadsResolved && !resolved.startsWith(uploadsResolved + path.sep)) {
+ return null;
}
-};
+ return resolved;
+}
+app.locals.safeUploadPath = safeUploadPath;
-// Configure multer for file uploads
-const storage = multer.diskStorage({
- destination: (req, file, cb) => {
- cb(null, 'uploads/');
+// --- Security middleware ---
+app.use(helmet({
+ contentSecurityPolicy: false, // existing inline styles/scripts in index.html; enable once frontend is cleaned up
+}));
+
+// CORS: only allow configured origins. Default = same-origin only (no CORS headers).
+const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
+ .split(',')
+ .map(s => s.trim())
+ .filter(Boolean);
+app.use(cors({
+ origin: (origin, cb) => {
+ if (!origin) return cb(null, true); // same-origin / curl
+ if (allowedOrigins.includes(origin)) return cb(null, true);
+ return cb(new Error('CORS: Origin nicht erlaubt'));
},
- filename: (req, file, cb) => {
- const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
- cb(null, uniqueSuffix + path.extname(file.originalname));
- }
-});
+ credentials: true,
+}));
-const upload = multer({
- storage: storage,
- fileFilter: fileFilter,
- limits: {
- fileSize: 10 * 1024 * 1024 // 10MB limit
- }
-});
+app.use(express.json({ limit: '1mb' }));
-// Validation helper functions
-function validateDate(dateString) {
- const date = new Date(dateString);
- return dateString && !isNaN(date.getTime()) && /^\d{4}-\d{2}-\d{2}$/.test(dateString);
+// API rate limit (login has its own stricter limiter in routes/auth.js)
+app.use('/api/', rateLimit({
+ windowMs: 60 * 1000,
+ max: 300,
+ standardHeaders: true,
+ legacyHeaders: false,
+}));
+
+// Sessions, stored in SQLite next to provision.db
+app.use(session({
+ store: new SQLiteStore({ db: 'sessions.db', dir: __dirname }),
+ secret: process.env.SESSION_SECRET || 'change-me-in-production',
+ resave: false,
+ saveUninitialized: false,
+ cookie: {
+ httpOnly: true,
+ sameSite: 'lax',
+ secure: isProd,
+ maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
+ },
+}));
+
+if (!process.env.SESSION_SECRET) {
+ console.warn('WARNING: SESSION_SECRET not set; using insecure default. Set it before deploying.');
}
-function validateHardware(hardware) {
- // Hardware should be 15 characters if provided
- return !hardware || hardware.length === 15;
-}
+// --- Public routes (login page + auth endpoints) ---
+const authRouter = require('./routes/auth');
+app.use('/api/auth', authRouter);
-function validateAmount(amount) {
- // Amount should be positive or zero
- return amount === null || amount === undefined || amount >= 0;
-}
-
-function validatePercentage(percentage) {
- // Percentage should be between 0 and 100
- return percentage === null || percentage === undefined || (percentage >= 0 && percentage <= 100);
-}
-
-// API Routes
-
-// Get all providers
-app.get('/api/anbieter', (req, res) => {
- const db = getDatabase();
- db.all('SELECT * FROM anbieter ORDER BY name', (err, rows) => {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- res.json(rows);
-
- });
+// Serve login page without auth
+app.get(['/login', '/login.html'], (req, res) => {
+ res.sendFile(path.join(__dirname, 'public', 'login.html'));
+});
+app.get('/login.js', (req, res) => {
+ res.sendFile(path.join(__dirname, 'public', 'login.js'));
});
-// Add new provider
-app.post('/api/anbieter', (req, res) => {
- const { name } = req.body;
- const db = getDatabase();
-
- db.run('INSERT INTO anbieter (name) VALUES (?)', [name], function(err) {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- res.json({ id: this.lastID, name });
-
- });
+// --- Auth gate for all /api/* except /api/auth/* ---
+app.use('/api', (req, res, next) => {
+ if (req.path.startsWith('/auth/')) return next();
+ return requireLogin(req, res, next);
});
-// Delete provider
-app.delete('/api/anbieter/:id', (req, res) => {
- const { id } = req.params;
- const db = getDatabase();
-
- // Check if provider has tariffs
- db.get('SELECT COUNT(*) as tariff_count FROM tarife WHERE anbieter_id = ?', [id], (err, tariffeRow) => {
- if (err) {
- res.status(500).json({ error: err.message });
-
- return;
- }
-
- // Check if provider has schaltungen
- db.get('SELECT COUNT(*) as schaltung_count FROM schaltungen WHERE anbieter_id = ?', [id], (err, schaltungenRow) => {
- if (err) {
- res.status(500).json({ error: err.message });
-
- return;
- }
-
- // Check if provider has global commission rules
- // Only check critical dependencies (tariffs and schaltungen)
- const criticalUsages = tariffeRow.tariff_count + schaltungenRow.schaltung_count;
-
- if (criticalUsages > 0) {
- const reasons = [];
- if (tariffeRow.tariff_count > 0) reasons.push(`${tariffeRow.tariff_count} Tarif(e)`);
- if (schaltungenRow.schaltung_count > 0) reasons.push(`${schaltungenRow.schaltung_count} Schaltung(en)`);
-
- res.status(400).json({
- error: `Anbieter kann nicht gelöscht werden: ${reasons.join(', ')} verwenden diesen Anbieter`
- });
-
- return;
- }
-
- // First delete all associated commission rules (these can be safely deleted)
- db.run('DELETE FROM anbieter_provisionsregeln WHERE anbieter_id = ?', [id], function(err) {
- if (err) {
- res.status(500).json({ error: err.message });
-
- return;
- }
-
- const deletedRules = this.changes;
-
- // Then delete the provider
- db.run('DELETE FROM anbieter WHERE id = ?', [id], function(err) {
- if (err) {
- res.status(500).json({ error: err.message });
-
- return;
- }
- if (this.changes === 0) {
- res.status(404).json({ error: 'Anbieter nicht gefunden' });
-
- return;
- }
-
- let message = 'Anbieter erfolgreich gelöscht';
- if (deletedRules > 0) {
- message += ` (${deletedRules} Provisionsregel(n) wurden mit gelöscht)`;
- }
-
- res.json({ message });
-
- });
- });
- });
- });
-});
+// --- Protected API routes ---
+const anbieterRouter = require('./routes/anbieter');
+const tarifeRouter = require('./routes/tarife');
+const schaltungenRouter = require('./routes/schaltungen');
+const tarifoptionenRouter = require('./routes/tarifoptionen');
+const dokumenteRouter = require('./routes/dokumente');
+const provisionenRouter = require('./routes/provisionen');
+const provisionsregelnRouter = require('./routes/provisionsregeln');
+const mitarbeiterRouter = require('./routes/mitarbeiter');
-// Get tariffs by provider
-// Get all tarife with anbieter names
-app.get('/api/tarife', (req, res) => {
- const db = getDatabase();
-
- const query = `
- SELECT t.*, a.name as anbieter_name
- FROM tarife t
- JOIN anbieter a ON t.anbieter_id = a.id
- ORDER BY a.name, t.name
- `;
-
- db.all(query, [], (err, rows) => {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- res.json(rows);
-
- });
-});
+app.use('/api/anbieter', anbieterRouter);
+app.use('/api/tarife', tarifeRouter);
+app.use('/api/schaltungen', schaltungenRouter);
+app.use('/api/tarifoptionen', tarifoptionenRouter);
+app.use('/api/mitarbeiter-prozentsaetze', mitarbeiterRouter);
+app.use('/api', dokumenteRouter); // /api/schaltungen/:id/dokumente, /api/dokumente/:id/*
+app.use('/api', provisionenRouter); // /api/provisionen, /api/schaltungen/:id/provision, /api/geloeschte-schaltungen
+app.use('/api', provisionsregelnRouter); // /api/provisionsregeln, /api/anbieter-provisionsregeln
-app.get('/api/tarife/:anbieter_id', (req, res) => {
- const { anbieter_id } = req.params;
- const db = getDatabase();
-
- db.all('SELECT * FROM tarife WHERE anbieter_id = ? ORDER BY name', [anbieter_id], (err, rows) => {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- res.json(rows);
-
- });
-});
-
-// Get single tariff by ID
-app.get('/api/tarif/:id', (req, res) => {
- const { id } = req.params;
- const db = getDatabase();
-
- db.get('SELECT * FROM tarife WHERE id = ?', [id], (err, row) => {
- if (err) {
- res.status(500).json({ error: err.message });
-
- return;
- }
- if (!row) {
- res.status(404).json({ error: 'Tarif nicht gefunden' });
-
- return;
- }
+// Singular tariff endpoint (kept for frontend backward compat)
+app.get('/api/tarif/:id', requireLogin, async (req, res, next) => {
+ const db = require('./db');
+ try {
+ const row = await db.get('SELECT * FROM tarife WHERE id = ?', [req.params.id]);
+ if (!row) return res.status(404).json({ error: 'Tarif nicht gefunden' });
res.json(row);
-
- });
+ } catch (err) { next(err); }
});
-// Add new tariff - simplified
-app.post('/api/tarife', (req, res) => {
- const { name, anbieter_id, provision_euro, beschreibung } = req.body;
+// --- Static files (after auth gate so /api routes don't accidentally serve assets) ---
+// Frontend gates itself via /api/auth/me; static files (login page, JS, CSS) are public.
+app.use(express.static(path.join(__dirname, 'public')));
- // Input validation
- if (!name || !name.trim()) {
- return res.status(400).json({ error: 'Tarifname ist erforderlich' });
- }
- if (!anbieter_id) {
- return res.status(400).json({ error: 'Anbieter ist erforderlich' });
- }
- if (!validateAmount(provision_euro)) {
- return res.status(400).json({ error: 'Provision muss eine positive Zahl sein' });
- }
-
- const db = getDatabase();
-
- const query = `INSERT INTO tarife (name, anbieter_id, provision_euro, beschreibung) VALUES (?, ?, ?, ?)`;
-
- db.run(query, [name, anbieter_id, provision_euro || 0, beschreibung || null], function(err) {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- res.json({
- id: this.lastID,
- name,
- anbieter_id,
- provision_euro: provision_euro || 0,
- beschreibung
- });
-
- });
+app.get('/', (req, res) => {
+ res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
-// Update tariff - simplified
-app.put('/api/tarife/:id', (req, res) => {
- const { id } = req.params;
- const { name, provision_euro, beschreibung } = req.body;
- const db = getDatabase();
-
- if (!name) {
- return res.status(400).json({ error: 'Tarif-Name ist erforderlich' });
- }
-
- const query = `UPDATE tarife SET name = ?, provision_euro = ?, beschreibung = ? WHERE id = ?`;
-
- db.run(query, [name, provision_euro || 0, beschreibung || null, id], function(err) {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- if (this.changes === 0) {
- res.status(404).json({ error: 'Tarif nicht gefunden' });
- return;
- }
- res.json({ message: 'Tarif erfolgreich aktualisiert' });
-
- });
-});
-
-// Delete tariff
-app.delete('/api/tarife/:id', (req, res) => {
- const { id } = req.params;
- const db = getDatabase();
-
- // Fast check if tariff is used in any schaltungen
- db.get('SELECT EXISTS(SELECT 1 FROM schaltungen WHERE tarif_id = ?) as has_schaltungen', [id], (err, row) => {
- if (err) {
- res.status(500).json({ error: err.message });
-
- return;
- }
-
- if (row.has_schaltungen) {
- res.status(400).json({
- error: `Tarif kann nicht gelöscht werden: Tarif wird in Schaltungen verwendet`
- });
-
- return;
- }
-
- // Delete the tariff
- db.run('DELETE FROM tarife WHERE id = ?', [id], function(err) {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- if (this.changes === 0) {
- res.status(404).json({ error: 'Tarif nicht gefunden' });
- return;
- }
- res.json({ message: 'Tarif erfolgreich gelöscht' });
-
- });
- });
-});
-
-// Get all schaltungen with related data
-app.get('/api/schaltungen', (req, res) => {
- const db = getDatabase();
- const query = `
- SELECT
- s.*,
- a.name as anbieter_name,
- t.name as tarif_name
- FROM schaltungen s
- JOIN anbieter a ON s.anbieter_id = a.id
- JOIN tarife t ON s.tarif_id = t.id
- ORDER BY s.created_at DESC
- `;
-
- db.all(query, (err, rows) => {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- res.json(rows);
-
- });
-});
-
-// Add new schaltung
-app.post('/api/schaltungen', (req, res) => {
- const {
- schaltungs_id,
- eingabe_datum,
- schaltungs_datum,
- vorname,
- nachname,
- firma,
- rufnummer,
- anbieter_id,
- tarif_id,
- hardware,
- hardware_ek,
- kundenverkaufspreis_netto
- } = req.body;
-
- // Input validation
- if (!schaltungs_id || !schaltungs_id.trim()) {
- return res.status(400).json({ error: 'Schaltungs-ID ist erforderlich' });
- }
- if (!validateDate(eingabe_datum)) {
- return res.status(400).json({ error: 'Ungültiges Eingabe-Datum (Format: YYYY-MM-DD)' });
- }
- if (!validateDate(schaltungs_datum)) {
- return res.status(400).json({ error: 'Ungültiges Schaltungs-Datum (Format: YYYY-MM-DD)' });
- }
- if (!vorname || !vorname.trim()) {
- return res.status(400).json({ error: 'Vorname ist erforderlich' });
- }
- if (!nachname || !nachname.trim()) {
- return res.status(400).json({ error: 'Nachname ist erforderlich' });
- }
- if (!rufnummer || !rufnummer.trim()) {
- return res.status(400).json({ error: 'Rufnummer ist erforderlich' });
- }
- if (!anbieter_id) {
- return res.status(400).json({ error: 'Anbieter ist erforderlich' });
- }
- if (!tarif_id) {
- return res.status(400).json({ error: 'Tarif ist erforderlich' });
- }
- if (!validateHardware(hardware)) {
- return res.status(400).json({ error: 'Hardware muss genau 15 Zeichen lang sein' });
- }
-
- const db = getDatabase();
-
- // Validation: if hardware is provided, hardware_ek and kundenverkaufspreis_netto must be provided
- if (hardware && hardware.trim() !== '') {
- if (!validateAmount(hardware_ek)) {
- return res.status(400).json({ error: 'Hardware-EK muss eine positive Zahl sein' });
- }
- if (!validateAmount(kundenverkaufspreis_netto)) {
- return res.status(400).json({ error: 'Kundenverkaufspreis muss eine positive Zahl sein' });
- }
- }
-
- const query = `
- INSERT INTO schaltungen
- (schaltungs_id, eingabe_datum, schaltungs_datum, vorname, nachname, firma, rufnummer, anbieter_id, tarif_id, hardware, hardware_ek, kundenverkaufspreis_netto)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `;
-
- db.run(query, [
- schaltungs_id, eingabe_datum, schaltungs_datum, vorname, nachname,
- firma, rufnummer, anbieter_id, tarif_id, hardware,
- hardware_ek || null, kundenverkaufspreis_netto || null
- ], function(err) {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- res.json({ id: this.lastID, message: 'Schaltung erfolgreich erstellt' });
-
- });
-});
-
-// Update schaltung
-app.put('/api/schaltungen/:id', (req, res) => {
- const { id } = req.params;
- const {
- schaltungs_id,
- eingabe_datum,
- schaltungs_datum,
- vorname,
- nachname,
- firma,
- rufnummer,
- anbieter_id,
- tarif_id,
- hardware,
- hardware_ek,
- kundenverkaufspreis_netto,
- tarifoptionen = []
- } = req.body;
-
- const db = getDatabase();
-
- // Begin transaction
- db.serialize(() => {
- db.run('BEGIN TRANSACTION');
-
- // Update schaltung
- const updateQuery = `
- UPDATE schaltungen
- SET schaltungs_id = ?, eingabe_datum = ?, schaltungs_datum = ?, vorname = ?, nachname = ?,
- firma = ?, rufnummer = ?, anbieter_id = ?, tarif_id = ?, hardware = ?,
- hardware_ek = ?, kundenverkaufspreis_netto = ?
- WHERE id = ?
- `;
-
- db.run(updateQuery, [
- schaltungs_id, eingabe_datum, schaltungs_datum, vorname, nachname,
- firma, rufnummer, anbieter_id, tarif_id, hardware,
- hardware_ek, kundenverkaufspreis_netto, id
- ], function(err) {
- if (err) {
- db.run('ROLLBACK');
- res.status(500).json({ error: err.message });
-
- return;
- }
- if (this.changes === 0) {
- db.run('ROLLBACK');
- res.status(404).json({ error: 'Schaltung nicht gefunden' });
-
- return;
- }
-
- // Delete existing tarifoptionen
- db.run('DELETE FROM schaltungen_tarifoptionen WHERE schaltung_id = ?', [id], (err) => {
- if (err) {
- db.run('ROLLBACK');
- res.status(500).json({ error: err.message });
-
- return;
- }
-
- // Insert new tarifoptionen
- if (tarifoptionen.length > 0) {
- const insertTarifoptionQuery = 'INSERT INTO schaltungen_tarifoptionen (schaltung_id, tarifoption_id) VALUES (?, ?)';
- let insertCount = 0;
-
- tarifoptionen.forEach(tarifoptionId => {
- db.run(insertTarifoptionQuery, [id, tarifoptionId], (err) => {
- if (err) {
- db.run('ROLLBACK');
- res.status(500).json({ error: err.message });
-
- return;
- }
-
- insertCount++;
- if (insertCount === tarifoptionen.length) {
- // All tarifoptionen inserted successfully
- db.run('COMMIT');
- res.json({ message: 'Schaltung erfolgreich aktualisiert' });
-
- }
- });
- });
- } else {
- // No tarifoptionen to insert
- db.run('COMMIT');
- res.json({ message: 'Schaltung erfolgreich aktualisiert' });
-
- }
- });
- });
- });
-});
-
-// Get tarifoptionen for a specific schaltung
-app.get('/api/schaltungen/:id/tarifoptionen', (req, res) => {
- const { id } = req.params;
- const db = getDatabase();
-
- const query = `
- SELECT st.tarifoption_id, tarifoptionen.name, tarifoptionen.provision_euro
- FROM schaltungen_tarifoptionen st
- JOIN tarifoptionen ON st.tarifoption_id = tarifoptionen.id
- WHERE st.schaltung_id = ?
- `;
-
- db.all(query, [id], (err, rows) => {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- res.json(rows);
-
- });
-});
-
-// Delete schaltung with reason tracking
-app.delete('/api/schaltungen/:id', (req, res) => {
- const { id } = req.params;
- const { loeschgrund } = req.body;
-
- if (!loeschgrund || loeschgrund.trim().length === 0) {
- return res.status(400).json({ error: 'Löschgrund ist erforderlich' });
- }
-
- const db = getDatabase();
-
- // Get schaltung details before deleting
- const getQuery = `
- SELECT
- s.*,
- a.name as anbieter_name,
- t.name as tarif_name
- FROM schaltungen s
- JOIN anbieter a ON s.anbieter_id = a.id
- JOIN tarife t ON s.tarif_id = t.id
- WHERE s.id = ?
- `;
-
- db.get(getQuery, [id], (err, schaltung) => {
- if (err) {
- res.status(500).json({ error: err.message });
-
- return;
- }
-
- if (!schaltung) {
- res.status(404).json({ error: 'Schaltung nicht gefunden' });
-
- return;
- }
-
- // Insert into deleted schaltungen table
- const insertQuery = `
- INSERT INTO geloeschte_schaltungen (
- original_id, schaltungs_id, eingabe_datum, schaltungs_datum, vorname, nachname,
- firma, rufnummer, anbieter_id, tarif_id, hardware, anbieter_name, tarif_name, loeschgrund
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `;
-
- db.run(insertQuery, [
- schaltung.id, schaltung.schaltungs_id, schaltung.eingabe_datum, schaltung.schaltungs_datum,
- schaltung.vorname, schaltung.nachname, schaltung.firma, schaltung.rufnummer,
- schaltung.anbieter_id, schaltung.tarif_id, schaltung.hardware,
- schaltung.anbieter_name, schaltung.tarif_name, loeschgrund.trim()
- ], function(insertErr) {
- if (insertErr) {
- res.status(500).json({ error: insertErr.message });
-
- return;
- }
-
- // Get all documents for this schaltung before deleting
- db.all('SELECT filepath FROM dokumente WHERE schaltung_id = ?', [id], (err, documents) => {
- if (err) {
- console.error('Error fetching documents for deletion:', err.message);
- }
-
- // Delete physical files from filesystem
- if (documents && documents.length > 0) {
- documents.forEach(doc => {
- const filePath = path.join(__dirname, doc.filepath);
- fs.unlink(filePath, (unlinkErr) => {
- if (unlinkErr) {
- console.error(`Error deleting file ${filePath}:`, unlinkErr.message);
- } else {
- console.log(`Deleted file: ${filePath}`);
- }
- });
- });
- }
-
- // Delete documents from database (will CASCADE from schaltungen deletion)
- // Delete from schaltungen table (this will CASCADE delete dokumente and schaltungen_tarifoptionen)
- db.run('DELETE FROM schaltungen WHERE id = ?', [id], function(deleteErr) {
- if (deleteErr) {
- res.status(500).json({ error: deleteErr.message });
- return;
- }
-
- res.json({
- message: 'Schaltung erfolgreich gelöscht und archiviert',
- loeschgrund: loeschgrund.trim(),
- deleted_files: documents ? documents.length : 0
- });
-
- });
- });
- });
- });
-});
-
-// Get deleted schaltungen
-app.get('/api/geloeschte-schaltungen', (req, res) => {
- const db = getDatabase();
-
- db.all('SELECT * FROM geloeschte_schaltungen ORDER BY geloescht_am DESC', (err, rows) => {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- res.json(rows);
-
- });
-});
-
-// Upload document for schaltung
-app.post('/api/schaltungen/:id/dokumente', upload.single('document'), (req, res) => {
- const schaltung_id = req.params.id;
- const file = req.file;
-
- if (!file) {
- return res.status(400).json({ error: 'Keine Datei hochgeladen' });
- }
-
- const db = getDatabase();
-
- // First verify schaltung exists
- db.get('SELECT id FROM schaltungen WHERE id = ?', [schaltung_id], (err, schaltung) => {
- if (err) {
- // Cleanup uploaded file
- fs.unlink(file.path, (unlinkErr) => {
- if (unlinkErr) console.error('Error cleaning up file:', unlinkErr);
- });
- return res.status(500).json({ error: err.message });
- }
-
- if (!schaltung) {
- // Cleanup uploaded file
- fs.unlink(file.path, (unlinkErr) => {
- if (unlinkErr) console.error('Error cleaning up file:', unlinkErr);
- });
- return res.status(404).json({ error: 'Schaltung nicht gefunden' });
- }
-
- const query = `
- INSERT INTO dokumente (schaltung_id, filename, original_filename, filepath, filesize, mimetype)
- VALUES (?, ?, ?, ?, ?, ?)
- `;
-
- db.run(query, [
- schaltung_id,
- file.filename,
- file.originalname,
- file.path,
- file.size,
- file.mimetype
- ], function(err) {
- if (err) {
- // Cleanup uploaded file on database error
- fs.unlink(file.path, (unlinkErr) => {
- if (unlinkErr) console.error('Error cleaning up file:', unlinkErr);
- });
- return res.status(500).json({ error: err.message });
- }
- res.json({
- id: this.lastID,
- message: 'Dokument erfolgreich hochgeladen',
- filename: file.originalname
- });
- });
- });
-});
-
-// Error handler for multer
+// --- Error handler (must be last) ---
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'Datei zu groß. Maximum: 10MB' });
}
return res.status(400).json({ error: err.message });
- } else if (err) {
- return res.status(400).json({ error: err.message });
}
- next();
-});
-
-// Get documents for schaltung
-app.get('/api/schaltungen/:id/dokumente', (req, res) => {
- const schaltung_id = req.params.id;
- const db = getDatabase();
-
- db.all('SELECT * FROM dokumente WHERE schaltung_id = ?', [schaltung_id], (err, rows) => {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- res.json(rows);
-
- });
-});
-
-// Download document
-app.get('/api/dokumente/:id/download', (req, res) => {
- const document_id = req.params.id;
- const db = getDatabase();
-
- db.get('SELECT * FROM dokumente WHERE id = ?', [document_id], (err, row) => {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- if (!row) {
- res.status(404).json({ error: 'Dokument nicht gefunden' });
- return;
- }
-
- const filepath = path.join(__dirname, row.filepath);
- res.download(filepath, row.original_filename);
-
- });
-});
-
-// Delete document
-app.delete('/api/dokumente/:id', (req, res) => {
- const document_id = req.params.id;
- const db = getDatabase();
-
- // First get the document info to delete the file
- db.get('SELECT * FROM dokumente WHERE id = ?', [document_id], (err, row) => {
- if (err) {
- res.status(500).json({ error: err.message });
-
- return;
- }
- if (!row) {
- res.status(404).json({ error: 'Dokument nicht gefunden' });
-
- return;
- }
-
- // Delete from database
- db.run('DELETE FROM dokumente WHERE id = ?', [document_id], function(err) {
- if (err) {
- res.status(500).json({ error: err.message });
-
- return;
- }
-
- // Delete physical file
- const filepath = path.join(__dirname, row.filepath);
- fs.unlink(filepath, (unlinkErr) => {
- if (unlinkErr) {
- console.error('Fehler beim Löschen der Datei:', unlinkErr);
- // Continue anyway as database record is already deleted
- }
- res.json({ message: 'Dokument erfolgreich gelöscht' });
-
- });
- });
- });
-});
-
-// Global Provider Commission Rules API
-
-// Get global commission rules for provider
-app.get('/api/anbieter/:id/provisionsregeln', (req, res) => {
- const { id } = req.params;
- const db = getDatabase();
-
- const query = `
- SELECT apr.*, a.name as anbieter_name
- FROM anbieter_provisionsregeln apr
- JOIN anbieter a ON apr.anbieter_id = a.id
- WHERE apr.anbieter_id = ? AND apr.aktiv = 1
- ORDER BY apr.gueltig_ab DESC
- `;
-
- db.all(query, [id], (err, rows) => {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- res.json(rows);
-
- });
-});
-
-// Add global provider commission rule
-app.post('/api/anbieter-provisionsregeln', (req, res) => {
- const {
- anbieter_id,
- provision_typ,
- betrag,
- beschreibung,
- gueltig_ab,
- verkaeufer_provision_typ,
- verkaeufer_provision_betrag,
- verkaeufer_provision_beschreibung
- } = req.body;
- const db = getDatabase();
-
- // Validate provision type and amount
- if (!['fix', 'prozent'].includes(provision_typ)) {
- return res.status(400).json({ error: 'Ungültiger Provisionstyp' });
+ if (err && err.status === 404) {
+ return res.status(404).json({ error: err.message });
}
-
- if (provision_typ === 'prozent' && (betrag < 0 || betrag > 100)) {
- return res.status(400).json({ error: 'Prozentual muss zwischen 0 und 100 sein' });
+ if (err && /CORS/.test(err.message)) {
+ return res.status(403).json({ error: err.message });
}
-
- if (provision_typ === 'fix' && betrag < 0) {
- return res.status(400).json({ error: 'Fixer Betrag kann nicht negativ sein' });
- }
-
- const query = `
- INSERT INTO anbieter_provisionsregeln (
- anbieter_id, provision_typ, betrag, beschreibung, gueltig_ab,
- verkaeufer_provision_typ, verkaeufer_provision_betrag, verkaeufer_provision_beschreibung
- )
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- `;
-
- db.run(query, [
- anbieter_id,
- provision_typ,
- betrag,
- beschreibung,
- gueltig_ab || new Date().toISOString().split('T')[0],
- verkaeufer_provision_typ || null,
- verkaeufer_provision_betrag || null,
- verkaeufer_provision_beschreibung || null
- ], function(err) {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- res.json({
- id: this.lastID,
- message: 'Globale Provisionsregel erfolgreich erstellt'
- });
-
- });
-});
-
-// Update global provider commission rule
-app.put('/api/anbieter-provisionsregeln/:id', (req, res) => {
- const { id } = req.params;
- const {
- provision_typ,
- betrag,
- beschreibung,
- gueltig_ab,
- aktiv,
- verkaeufer_provision_typ,
- verkaeufer_provision_betrag,
- verkaeufer_provision_beschreibung
- } = req.body;
- const db = getDatabase();
-
- // First get the current rule to preserve existing values
- db.get('SELECT * FROM anbieter_provisionsregeln WHERE id = ?', [id], (err, current) => {
- if (err) {
- res.status(500).json({ error: err.message });
-
- return;
- }
-
- if (!current) {
- res.status(404).json({ error: 'Globale Provisionsregel nicht gefunden' });
-
- return;
- }
-
- const updateQuery = `
- UPDATE anbieter_provisionsregeln
- SET provision_typ = ?, betrag = ?, beschreibung = ?, gueltig_ab = ?, aktiv = ?,
- verkaeufer_provision_typ = ?, verkaeufer_provision_betrag = ?, verkaeufer_provision_beschreibung = ?
- WHERE id = ?
- `;
-
- db.run(updateQuery, [
- provision_typ !== undefined ? provision_typ : current.provision_typ,
- betrag !== undefined ? betrag : current.betrag,
- beschreibung !== undefined ? beschreibung : current.beschreibung,
- gueltig_ab !== undefined ? gueltig_ab : current.gueltig_ab,
- aktiv !== undefined ? aktiv : current.aktiv,
- verkaeufer_provision_typ !== undefined ? verkaeufer_provision_typ : current.verkaeufer_provision_typ,
- verkaeufer_provision_betrag !== undefined ? verkaeufer_provision_betrag : current.verkaeufer_provision_betrag,
- verkaeufer_provision_beschreibung !== undefined ? verkaeufer_provision_beschreibung : current.verkaeufer_provision_beschreibung,
- id
- ], function(err) {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- res.json({ message: 'Globale Provisionsregel erfolgreich aktualisiert' });
-
- });
- });
-});
-
-// Commission Rules API
-
-// Get commission rules for tariff
-app.get('/api/tarife/:id/provisionsregeln', (req, res) => {
- const { id } = req.params;
- const db = getDatabase();
-
- const query = `
- SELECT pr.*, t.name as tarif_name, a.name as anbieter_name
- FROM provisionsregeln pr
- JOIN tarife t ON pr.tarif_id = t.id
- JOIN anbieter a ON t.anbieter_id = a.id
- WHERE pr.tarif_id = ? AND pr.aktiv = 1
- ORDER BY pr.gueltig_ab DESC
- `;
-
- db.all(query, [id], (err, rows) => {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- res.json(rows);
-
- });
-});
-
-// Add commission rule
-app.post('/api/provisionsregeln', (req, res) => {
- const { tarif_id, provision_typ, betrag, beschreibung, gueltig_ab } = req.body;
- const db = getDatabase();
-
- // Validate provision type and amount
- if (!['fix', 'prozent'].includes(provision_typ)) {
- return res.status(400).json({ error: 'Ungültiger Provisionstyp' });
- }
-
- if (provision_typ === 'prozent' && (betrag < 0 || betrag > 100)) {
- return res.status(400).json({ error: 'Prozentual muss zwischen 0 und 100 sein' });
- }
-
- if (provision_typ === 'fix' && betrag < 0) {
- return res.status(400).json({ error: 'Fixer Betrag kann nicht negativ sein' });
- }
-
- const query = `
- INSERT INTO provisionsregeln (tarif_id, provision_typ, betrag, beschreibung, gueltig_ab)
- VALUES (?, ?, ?, ?, ?)
- `;
-
- db.run(query, [tarif_id, provision_typ, betrag, beschreibung, gueltig_ab || new Date().toISOString().split('T')[0]], function(err) {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- res.json({
- id: this.lastID,
- message: 'Provisionsregel erfolgreich erstellt'
- });
-
- });
-});
-
-// Update commission rule
-app.put('/api/provisionsregeln/:id', (req, res) => {
- const { id } = req.params;
- const { provision_typ, betrag, beschreibung, gueltig_ab, aktiv } = req.body;
- const db = getDatabase();
-
- // First get the current rule to preserve existing values
- db.get('SELECT * FROM provisionsregeln WHERE id = ?', [id], (err, current) => {
- if (err) {
- res.status(500).json({ error: err.message });
-
- return;
- }
-
- if (!current) {
- res.status(404).json({ error: 'Provisionsregel nicht gefunden' });
-
- return;
- }
-
- // Use current values as defaults
- const updateQuery = `
- UPDATE provisionsregeln
- SET provision_typ = ?, betrag = ?, beschreibung = ?, gueltig_ab = ?, aktiv = ?
- WHERE id = ?
- `;
-
- db.run(updateQuery, [
- provision_typ !== undefined ? provision_typ : current.provision_typ,
- betrag !== undefined ? betrag : current.betrag,
- beschreibung !== undefined ? beschreibung : current.beschreibung,
- gueltig_ab !== undefined ? gueltig_ab : current.gueltig_ab,
- aktiv !== undefined ? aktiv : current.aktiv,
- id
- ], function(err) {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- res.json({ message: 'Provisionsregel erfolgreich aktualisiert' });
-
- });
- });
-});
-
-// Get commission calculation for schaltung
-app.get('/api/schaltungen/:id/provision', (req, res) => {
- const { id } = req.params;
- const db = getDatabase();
-
- const query = `
- SELECT
- s.*,
- t.name as tarif_name,
- t.provision_euro as tarif_provision_euro,
- t.beschreibung as tarif_beschreibung,
- a.name as anbieter_name,
- mp.prozentsatz as mitarbeiter_prozentsatz
- FROM schaltungen s
- JOIN tarife t ON s.tarif_id = t.id
- JOIN anbieter a ON s.anbieter_id = a.id
- LEFT JOIN mitarbeiter_prozentsaetze mp ON a.id = mp.anbieter_id
- AND mp.gueltig_ab <= s.schaltungs_datum
- WHERE s.id = ?
- ORDER BY mp.gueltig_ab DESC
- LIMIT 1
- `;
-
- db.get(query, [id], (err, row) => {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
-
- if (!row) {
- res.status(404).json({ error: 'Schaltung nicht gefunden' });
- return;
- }
-
- // Get tarifoptionen for this schaltung
- const tarifoptionenQuery = `
- SELECT to.*
- FROM tarifoptionen to
- JOIN schaltungen_tarifoptionen st ON to.id = st.tarifoption_id
- WHERE st.schaltung_id = ?
- `;
-
- db.all(tarifoptionenQuery, [id], (err, tarifoptionen) => {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
-
- // Calculate shop commission with hierarchy
- let shop_provision_typ = null;
- let shop_provision_betrag = null;
- let shop_provision_beschreibung = null;
- let berechnete_shop_provision = 0;
- let shop_quelle = 'Keine Shop-Provision definiert';
-
- // Priority 1: Direct tariff commission
- if (row.tarif_provision_typ && row.tarif_provision_betrag) {
- shop_provision_typ = row.tarif_provision_typ;
- shop_provision_betrag = row.tarif_provision_betrag;
- shop_provision_beschreibung = row.tarif_provision_beschreibung;
- shop_quelle = 'Direkte Tarif-Provision';
- }
- // Priority 2: Tariff-specific rule
- else if (row.regel_provision_typ && row.regel_provision_betrag) {
- shop_provision_typ = row.regel_provision_typ;
- shop_provision_betrag = row.regel_provision_betrag;
- shop_provision_beschreibung = row.regel_provision_beschreibung;
- shop_quelle = 'Tarif-spezifische Regel';
- }
- // Priority 3: Global provider rule as fallback
- else if (row.global_provision_typ && row.global_provision_betrag) {
- shop_provision_typ = row.global_provision_typ;
- shop_provision_betrag = row.global_provision_betrag;
- shop_provision_beschreibung = row.global_provision_beschreibung;
- shop_quelle = 'Globale Anbieter-Regel';
- }
-
- // Calculate shop commission
- if (shop_provision_typ && shop_provision_betrag) {
- if (shop_provision_typ === 'fix') {
- berechnete_shop_provision = parseFloat(shop_provision_betrag);
- } else if (shop_provision_typ === 'prozent') {
- const basisBetrag = 100; // This could come from tariff table
- berechnete_shop_provision = (parseFloat(shop_provision_betrag) / 100) * basisBetrag;
- }
- }
-
- // Add tarifoptionen to shop commission
- let tarifoptionen_provision = 0;
- let verwendete_tarifoptionen = [];
- if (tarifoptionen && tarifoptionen.length > 0) {
- tarifoptionen.forEach(option => {
- if (option.provision_betrag && option.provision_betrag > 0) {
- tarifoptionen_provision += parseFloat(option.provision_betrag);
- verwendete_tarifoptionen.push(option.name);
- }
- });
- }
-
- // Add tarifoptionen to total shop commission
- berechnete_shop_provision += tarifoptionen_provision;
-
- // Update shop commission description if tarifoptionen were added
- if (tarifoptionen_provision > 0) {
- const tarifoptionen_text = verwendete_tarifoptionen.join(', ');
- shop_provision_beschreibung = shop_provision_beschreibung
- ? `${shop_provision_beschreibung} + Tarifoptionen: ${tarifoptionen_text}`
- : `Tarifoptionen: ${tarifoptionen_text}`;
- }
-
- // Calculate employee commission with hierarchy
- let mitarbeiter_provision_typ = null;
- let mitarbeiter_provision_betrag = null;
- let mitarbeiter_provision_beschreibung = null;
- let berechnete_mitarbeiter_provision = 0;
- let mitarbeiter_quelle = 'Keine Mitarbeiter-Provision definiert';
-
- // Priority 1: Direct tariff employee commission
- if (row.tarif_verkaeufer_provision_typ && row.tarif_verkaeufer_provision_betrag) {
- mitarbeiter_provision_typ = row.tarif_verkaeufer_provision_typ;
- mitarbeiter_provision_betrag = row.tarif_verkaeufer_provision_betrag;
- mitarbeiter_provision_beschreibung = row.tarif_verkaeufer_provision_beschreibung;
- mitarbeiter_quelle = 'Direkte Tarif-Verkäufer-Provision';
- }
- // Priority 2: Tariff-specific rule employee commission
- else if (row.regel_verkaeufer_provision_typ && row.regel_verkaeufer_provision_betrag) {
- mitarbeiter_provision_typ = row.regel_verkaeufer_provision_typ;
- mitarbeiter_provision_betrag = row.regel_verkaeufer_provision_betrag;
- mitarbeiter_provision_beschreibung = row.regel_verkaeufer_provision_beschreibung;
- mitarbeiter_quelle = 'Tarif-spezifische Verkäufer-Regel';
- }
- // Priority 3: Global provider rule employee commission
- else if (row.global_verkaeufer_provision_typ && row.global_verkaeufer_provision_betrag) {
- mitarbeiter_provision_typ = row.global_verkaeufer_provision_typ;
- mitarbeiter_provision_betrag = row.global_verkaeufer_provision_betrag;
- mitarbeiter_provision_beschreibung = row.global_verkaeufer_provision_beschreibung;
- mitarbeiter_quelle = 'Globale Anbieter-Verkäufer-Regel';
- }
-
- // Calculate employee commission
- if (mitarbeiter_provision_typ && mitarbeiter_provision_betrag) {
- if (mitarbeiter_provision_typ === 'fix') {
- berechnete_mitarbeiter_provision = parseFloat(mitarbeiter_provision_betrag);
- } else if (mitarbeiter_provision_typ === 'prozent') {
- // If percentage, calculate based on shop commission
- berechnete_mitarbeiter_provision = (parseFloat(mitarbeiter_provision_betrag) / 100) * berechnete_shop_provision;
- }
- }
-
- res.json({
- id: row.id,
- schaltungs_id: row.schaltungs_id,
- schaltungs_datum: row.schaltungs_datum,
- vorname: row.vorname,
- nachname: row.nachname,
- firma: row.firma,
- tarif_name: row.tarif_name,
- anbieter_name: row.anbieter_name,
- // Shop commission
- shop_provision_typ,
- shop_provision_betrag,
- shop_provision_beschreibung,
- berechnete_shop_provision: berechnete_shop_provision.toFixed(2),
- shop_quelle,
- // Employee commission
- mitarbeiter_provision_typ,
- mitarbeiter_provision_betrag,
- mitarbeiter_provision_beschreibung,
- berechnete_mitarbeiter_provision: berechnete_mitarbeiter_provision.toFixed(2),
- mitarbeiter_quelle
- });
-
- });
- });
-});
-
-// Get commission overview for all schaltungen with hierarchy
-app.get('/api/provisionen', async (req, res) => {
- const db = getDatabase();
-
- // Simplified query for new commission model - use subquery to get latest valid percentage
- const query = `
- SELECT
- s.id,
- s.schaltungs_id,
- s.eingabe_datum,
- s.schaltungs_datum,
- s.vorname,
- s.nachname,
- s.firma,
- s.hardware,
- s.hardware_ek,
- s.kundenverkaufspreis_netto,
- t.name as tarif_name,
- t.provision_euro as tarif_provision_euro,
- a.name as anbieter_name,
- a.id as anbieter_id,
- (
- SELECT mp.prozentsatz
- FROM mitarbeiter_prozentsaetze mp
- WHERE mp.anbieter_id = a.id
- AND mp.gueltig_ab <= s.schaltungs_datum
- ORDER BY mp.gueltig_ab DESC
- LIMIT 1
- ) as mitarbeiter_prozentsatz
- FROM schaltungen s
- JOIN tarife t ON s.tarif_id = t.id
- JOIN anbieter a ON s.anbieter_id = a.id
- ORDER BY s.schaltungs_datum DESC
- `;
-
- try {
- const rows = await new Promise((resolve, reject) => {
- db.all(query, [], (err, data) => {
- if (err) reject(err);
- else resolve(data);
- });
- });
-
- // Get tarifoptionen for all schaltungen first
- const schaltungIds = rows.map(row => row.id);
- let tarifoptionenMap = {};
-
- if (schaltungIds.length > 0) {
- const tarifoptionenQuery = `
- SELECT
- st.schaltung_id,
- tarifoptionen.provision_euro
- FROM schaltungen_tarifoptionen st
- JOIN tarifoptionen ON st.tarifoption_id = tarifoptionen.id
- WHERE st.schaltung_id IN (${schaltungIds.map(() => '?').join(',')})
- `;
-
- try {
- const tarifoptionenRows = await new Promise((resolve, reject) => {
- db.all(tarifoptionenQuery, schaltungIds, (err, tarifoptionenData) => {
- if (err) reject(err);
- else resolve(tarifoptionenData);
- });
- });
-
- // Group tarifoptionen by schaltung_id
- tarifoptionenRows.forEach(opt => {
- if (!tarifoptionenMap[opt.schaltung_id]) {
- tarifoptionenMap[opt.schaltung_id] = [];
- }
- tarifoptionenMap[opt.schaltung_id].push(parseFloat(opt.provision_euro) || 0);
- });
- } catch (error) {
- console.error('Error loading tarifoptionen:', error);
- }
- }
-
- // Process new commission calculation with hardware pricing and tarifoptionen
- const processedRows = rows.map(row => {
- // Base shop commission = Tarif provision
- let shop_provision = parseFloat(row.tarif_provision_euro) || 0;
-
- // Add tarifoptionen provision
- const tarifoptionen = tarifoptionenMap[row.id] || [];
- const tarifoptionen_provision = tarifoptionen.reduce((sum, opt) => sum + opt, 0);
- shop_provision += tarifoptionen_provision;
-
- // Enhanced calculation: Tarifprovision + Tarifoptionsprovision - Hardware-EK + Kundenverkaufspreis
- if (row.hardware && row.hardware.trim() !== '') {
- const hardware_ek = parseFloat(row.hardware_ek) || 0;
- const kundenverkaufspreis = parseFloat(row.kundenverkaufspreis_netto) || 0;
-
- // Apply the new formula: Tarifprovision + Tarifoptionen - Hardware-EK + Kundenverkaufspreis
- shop_provision = shop_provision - hardware_ek + kundenverkaufspreis;
- }
-
- // Employee commission = Shop provision * Employee percentage
- const mitarbeiter_prozentsatz = parseFloat(row.mitarbeiter_prozentsatz) || 0;
- const mitarbeiter_provision = shop_provision * (mitarbeiter_prozentsatz / 100);
-
- return {
- id: row.id,
- schaltungs_id: row.schaltungs_id,
- eingabe_datum: row.eingabe_datum,
- schaltungs_datum: row.schaltungs_datum,
- vorname: row.vorname,
- nachname: row.nachname,
- firma: row.firma,
- hardware: row.hardware,
- hardware_ek: row.hardware_ek,
- kundenverkaufspreis_netto: row.kundenverkaufspreis_netto,
- tarif_name: row.tarif_name,
- anbieter_name: row.anbieter_name,
- // Legacy fields for frontend compatibility
- provision_typ: 'fix',
- provision_betrag: shop_provision,
- provision_beschreibung: 'Neue einfache Berechnung',
- berechnete_provision: shop_provision.toFixed(2),
- quelle: 'Tarif-Provision',
- // New fields
- shop_provision_euro: shop_provision,
- berechnete_shop_provision: shop_provision.toFixed(2),
- mitarbeiter_prozentsatz: mitarbeiter_prozentsatz,
- berechnete_mitarbeiter_provision: mitarbeiter_provision.toFixed(2),
- mitarbeiter_quelle: `${mitarbeiter_prozentsatz}% von ${shop_provision}€`
- };
- });
-
- // Calculate totals
- const totalShopProvision = processedRows.reduce((sum, row) => {
- return sum + parseFloat(row.berechnete_shop_provision);
- }, 0);
-
- const totalMitarbeiterProvision = processedRows.reduce((sum, row) => {
- return sum + parseFloat(row.berechnete_mitarbeiter_provision);
- }, 0);
-
- res.json({
- schaltungen: processedRows,
- total_provision: totalShopProvision.toFixed(2), // Legacy field
- total_shop_provision: totalShopProvision.toFixed(2),
- total_mitarbeiter_provision: totalMitarbeiterProvision.toFixed(2),
- total_gesamt_provision: (totalShopProvision + totalMitarbeiterProvision).toFixed(2)
- });
-
-
- } catch (error) {
- console.error('Error in provisions API:', error);
- res.status(500).json({ error: error.message });
-
- }
-});
-
-// Tarifoptionen API
-
-// Get all tarifoptionen with anbieter and tarif names for admin
-app.get('/api/tarifoptionen', (req, res) => {
- const db = getDatabase();
-
- const query = `
- SELECT
- t.*,
- a.name as anbieter_name,
- ta.name as tarif_name
- FROM tarifoptionen t
- JOIN anbieter a ON t.anbieter_id = a.id
- LEFT JOIN tarife ta ON t.tarif_id = ta.id
- WHERE t.aktiv = 1
- ORDER BY a.name, t.tarif_id IS NULL DESC, COALESCE(ta.name, ''), t.name
- `;
-
- console.log('Executing tarifoptionen query...');
- db.all(query, (err, rows) => {
- if (err) {
- console.error('Tarifoptionen API error:', err.message);
- res.status(500).json({ error: err.message });
- return;
- }
- console.log(`Tarifoptionen API: returning ${rows.length} results`);
- res.json(rows);
-
- });
-});
-
-// Get tarifoptionen by tariff (includes both tariff-specific and anbieter-wide options)
-app.get('/api/tarife/:tarif_id/tarifoptionen', (req, res) => {
- const { tarif_id } = req.params;
- const db = getDatabase();
-
- // Get all tarifoptionen for this tariff from the old table
- // (includes both manually created and auto-synced from new structure)
- const query = `
- SELECT * FROM tarifoptionen
- WHERE tarif_id = ? AND aktiv = 1
- ORDER BY name
- `;
-
- db.all(query, [tarif_id], (err, rows) => {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- res.json(rows);
-
- });
-});
-
-// Add new tarifoption
-app.post('/api/tarifoptionen', (req, res) => {
- const {
- anbieter_id,
- tarif_id,
- name,
- code,
- provision_euro,
- beschreibung
- } = req.body;
-
- // Validate that either anbieter_id is provided (for anbieter-wide) or tarif_id (for tariff-specific)
- if (!anbieter_id && !tarif_id) {
- return res.status(400).json({ error: 'Entweder Anbieter oder Tarif muss angegeben werden' });
- }
-
- const db = getDatabase();
-
- // If tarif_id is provided, get anbieter_id from tariff
- if (tarif_id && !anbieter_id) {
- db.get('SELECT anbieter_id FROM tarife WHERE id = ?', [tarif_id], (err, tarif) => {
- if (err) {
- res.status(500).json({ error: err.message });
-
- return;
- }
- if (!tarif) {
- res.status(404).json({ error: 'Tarif nicht gefunden' });
-
- return;
- }
-
- insertTarifoption(db, res, tarif.anbieter_id, tarif_id, name, code, provision_euro, beschreibung);
- });
- } else {
- // Direct anbieter-wide option
- insertTarifoption(db, res, anbieter_id, tarif_id || null, name, code, provision_euro, beschreibung);
- }
-});
-
-function insertTarifoption(db, res, anbieter_id, tarif_id, name, code, provision_euro, beschreibung) {
- const query = `
- INSERT INTO tarifoptionen (
- anbieter_id, tarif_id, name, code, provision_euro, beschreibung
- )
- VALUES (?, ?, ?, ?, ?, ?)
- `;
-
- db.run(query, [
- anbieter_id,
- tarif_id,
- name,
- code || null,
- provision_euro || 0,
- beschreibung || null
- ], function(err) {
- if (err) {
- if (err.message.includes('UNIQUE constraint failed')) {
- const scope = tarif_id ? 'für diesen Tarif' : 'für diesen Anbieter';
- res.status(400).json({ error: `Eine Tarifoption mit diesem Namen existiert bereits ${scope}` });
- } else {
- res.status(500).json({ error: err.message });
- }
- return;
- }
- res.json({
- id: this.lastID,
- message: 'Tarifoption erfolgreich erstellt'
- });
-
- });
-}
-
-// Get tarifoptionen for schaltung
-app.get('/api/schaltungen/:id/tarifoptionen', (req, res) => {
- const { id } = req.params;
- const db = getDatabase();
-
- const query = `
- SELECT t.*, st.id as schaltung_tarifoption_id
- FROM tarifoptionen t
- JOIN schaltungen_tarifoptionen st ON t.id = st.tarifoption_id
- WHERE st.schaltung_id = ?
- ORDER BY t.name
- `;
-
- db.all(query, [id], (err, rows) => {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- res.json(rows);
-
- });
-});
-
-// Add tarifoption to schaltung
-app.post('/api/schaltungen/:id/tarifoptionen', (req, res) => {
- const schaltung_id = req.params.id;
- const { tarifoption_id } = req.body;
- const db = getDatabase();
-
- const query = `
- INSERT INTO schaltungen_tarifoptionen (schaltung_id, tarifoption_id)
- VALUES (?, ?)
- `;
-
- db.run(query, [schaltung_id, tarifoption_id], function(err) {
- if (err) {
- if (err.message.includes('UNIQUE constraint failed')) {
- res.status(400).json({ error: 'Tarifoption bereits hinzugefügt' });
- } else {
- res.status(500).json({ error: err.message });
- }
- return;
- }
- res.json({
- id: this.lastID,
- message: 'Tarifoption erfolgreich hinzugefügt'
- });
-
- });
-});
-
-// Remove tarifoption from schaltung
-app.delete('/api/schaltungen/:schaltung_id/tarifoptionen/:tarifoption_id', (req, res) => {
- const { schaltung_id, tarifoption_id } = req.params;
- const db = getDatabase();
-
- db.run('DELETE FROM schaltungen_tarifoptionen WHERE schaltung_id = ? AND tarifoption_id = ?', [schaltung_id, tarifoption_id], function(err) {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- if (this.changes === 0) {
- res.status(404).json({ error: 'Verknüpfung nicht gefunden' });
- return;
- }
- res.json({ message: 'Tarifoption erfolgreich entfernt' });
-
- });
-});
-
-// Create new tarifoptionen with multiple tarif assignments
-app.post('/api/tarifoptionen/multi', (req, res) => {
- const {
- anbieter_id,
- tarif_ids = [], // Array of tarif IDs
- is_anbieter_weit = false,
- name,
- code,
- provision_euro,
- beschreibung
- } = req.body;
-
- if (!anbieter_id || !name) {
- return res.status(400).json({ error: 'Anbieter und Name sind erforderlich' });
- }
-
- if (!is_anbieter_weit && (!tarif_ids || tarif_ids.length === 0)) {
- return res.status(400).json({ error: 'Bei tarif-spezifischen Optionen müssen Tarife ausgewählt werden' });
- }
-
- const db = getDatabase();
-
- // Insert into new tarifoptionen table
- const insertQuery = `
- INSERT INTO tarifoptionen_new (name, code, provision_euro, beschreibung)
- VALUES (?, ?, ?, ?)
- `;
-
- db.run(insertQuery, [name, code || null, provision_euro || 0, beschreibung || null], function(err) {
- if (err) {
- console.error('Error inserting tarifoption:', err);
- res.status(500).json({ error: err.message });
-
- return;
- }
-
- const tarifoptionId = this.lastID;
-
- if (is_anbieter_weit) {
- // Get all tarifs for the anbieter
- db.all('SELECT id FROM tarife WHERE anbieter_id = ?', [anbieter_id], (err, tarifs) => {
- if (err) {
- res.status(500).json({ error: err.message });
-
- return;
- }
-
- // Insert into tarifoptionen_tarife for all tarifs
- const assignments = tarifs.map(tarif => [tarifoptionId, tarif.id]);
- insertTarifAssignmentsAndLegacy(db, res, tarifoptionId, assignments, name, code, provision_euro, beschreibung, anbieter_id);
- });
- } else {
- // Insert only selected tarifs
- const assignments = tarif_ids.map(tarif_id => [tarifoptionId, tarif_id]);
- insertTarifAssignmentsAndLegacy(db, res, tarifoptionId, assignments, name, code, provision_euro, beschreibung, anbieter_id);
- }
- });
-});
-
-function insertTarifAssignments(db, res, tarifoptionId, assignments) {
- const stmt = db.prepare('INSERT INTO tarifoptionen_tarife (tarifoption_id, tarif_id) VALUES (?, ?)');
-
- assignments.forEach(assignment => {
- stmt.run(assignment, (err) => {
- if (err) {
- console.error('Error inserting tarif assignment:', err);
- }
- });
- });
-
- stmt.finalize((err) => {
- if (err) {
- res.status(500).json({ error: err.message });
- } else {
- res.json({
- id: tarifoptionId,
- message: 'Tarifoption erfolgreich erstellt',
- assignments: assignments.length
- });
- }
-
- });
-}
-
-function insertTarifAssignmentsAndLegacy(db, res, tarifoptionId, assignments, name, code, provision_euro, beschreibung, anbieter_id) {
- // First insert into new structure (tarifoptionen_tarife)
- const stmt = db.prepare('INSERT INTO tarifoptionen_tarife (tarifoption_id, tarif_id) VALUES (?, ?)');
-
- assignments.forEach(assignment => {
- stmt.run(assignment, (err) => {
- if (err) {
- console.error('Error inserting tarif assignment:', err);
- }
- });
- });
-
- stmt.finalize((err) => {
- if (err) {
- res.status(500).json({ error: err.message });
-
- return;
- }
-
- // Now also insert into old structure (tarifoptionen) for backward compatibility with schaltungen
- const legacyStmt = db.prepare('INSERT OR IGNORE INTO tarifoptionen (anbieter_id, tarif_id, name, code, provision_euro, beschreibung) VALUES (?, ?, ?, ?, ?, ?)');
-
- assignments.forEach(assignment => {
- const tarif_id = assignment[1]; // assignment is [tarifoptionId, tarif_id]
- legacyStmt.run([anbieter_id, tarif_id, name, code, provision_euro || 0, beschreibung], (err) => {
- if (err) {
- console.error('Error inserting legacy tarifoption:', err);
- }
- });
- });
-
- legacyStmt.finalize((err) => {
- if (err) {
- console.error('Error finalizing legacy insert:', err);
- }
-
- res.json({
- id: tarifoptionId,
- message: 'Tarifoption erfolgreich erstellt',
- assignments: assignments.length
- });
-
- });
- });
-}
-
-// Get tarifoptionen with their tarif assignments
-app.get('/api/tarifoptionen/multi', (req, res) => {
- const db = getDatabase();
-
- const query = `
- SELECT
- tn.id,
- tn.name,
- tn.code,
- tn.provision_euro,
- tn.beschreibung,
- tn.aktiv,
- tn.created_at,
- GROUP_CONCAT(t.name || ' (' || a.name || ')') as tarif_names,
- GROUP_CONCAT(t.id) as tarif_ids
- FROM tarifoptionen_new tn
- LEFT JOIN tarifoptionen_tarife tt ON tn.id = tt.tarifoption_id
- LEFT JOIN tarife t ON tt.tarif_id = t.id
- LEFT JOIN anbieter a ON t.anbieter_id = a.id
- WHERE tn.aktiv = 1
- GROUP BY tn.id, tn.name, tn.code, tn.provision_euro, tn.beschreibung, tn.aktiv, tn.created_at
- ORDER BY tn.created_at DESC
- `;
-
- db.all(query, [], (err, rows) => {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
-
- res.json(rows);
-
- });
-});
-
-// Delete tarifoption from new structure
-app.delete('/api/tarifoptionen/multi/:id', (req, res) => {
- const { id } = req.params;
- const db = getDatabase();
-
- // First get the tarifoption details to delete from old structure
- db.get('SELECT name, code FROM tarifoptionen_new WHERE id = ?', [id], (err, option) => {
- if (err) {
- res.status(500).json({ error: err.message });
-
- return;
- }
-
- if (!option) {
- res.status(404).json({ error: 'Tarifoption nicht gefunden' });
-
- return;
- }
-
- // Delete from tarifoptionen_tarife first (foreign key constraint)
- db.run('DELETE FROM tarifoptionen_tarife WHERE tarifoption_id = ?', [id], function(err) {
- if (err) {
- res.status(500).json({ error: err.message });
-
- return;
- }
-
- // Delete from old structure (match by name and code - will delete all matching entries across all tarifs)
- db.run('DELETE FROM tarifoptionen WHERE name = ? AND (code = ? OR (code IS NULL AND ? IS NULL))',
- [option.name, option.code, option.code], function(err) {
- if (err) {
- console.error('Error deleting from old tarifoptionen:', err);
- // Don't fail the whole operation
- }
- console.log(`Deleted ${this.changes} entries from old tarifoptionen table`);
- });
-
- // Then delete from tarifoptionen_new
- db.run('DELETE FROM tarifoptionen_new WHERE id = ?', [id], function(err) {
- if (err) {
- res.status(500).json({ error: err.message });
-
- return;
- }
-
- res.json({ message: 'Tarifoption erfolgreich gelöscht' });
-
- });
- });
- });
-});
-
-// Update tarifoption in new structure
-app.put('/api/tarifoptionen/multi/:id', (req, res) => {
- const { id } = req.params;
- const { name, code, provision_euro, beschreibung, anbieter_id, is_anbieter_weit, tarif_ids } = req.body;
- const db = getDatabase();
-
- if (!name) {
- return res.status(400).json({ error: 'Tarifoption-Name ist erforderlich' });
- }
-
- db.serialize(() => {
- db.run('BEGIN TRANSACTION');
-
- // Update the tarifoption itself
- const updateQuery = `
- UPDATE tarifoptionen_new
- SET name = ?, code = ?, provision_euro = ?, beschreibung = ?
- WHERE id = ?
- `;
-
- db.run(updateQuery, [name, code, provision_euro || 0, beschreibung, id], function(err) {
- if (err) {
- db.run('ROLLBACK');
- res.status(500).json({ error: err.message });
-
- return;
- }
-
- if (this.changes === 0) {
- db.run('ROLLBACK');
- res.status(404).json({ error: 'Tarifoption nicht gefunden' });
-
- return;
- }
-
- // Delete existing tarif associations from new structure
- db.run('DELETE FROM tarifoptionen_tarife WHERE tarifoption_id = ?', [id], function(err) {
- if (err) {
- db.run('ROLLBACK');
- res.status(500).json({ error: err.message });
-
- return;
- }
-
- // Also delete from old structure (match by name and code for cleanup - will delete all matching entries)
- db.run('DELETE FROM tarifoptionen WHERE name = ? AND (code = ? OR (code IS NULL AND ? IS NULL))',
- [name, code, code], function(err) {
- if (err) {
- console.error('Error deleting old tarifoptionen:', err);
- // Don't fail the whole operation if old table cleanup fails
- }
- console.log(`Deleted ${this.changes} old tarifoptionen entries for update`);
- });
-
- // If anbieter-weit, get all tarifs for the anbieter
- if (is_anbieter_weit && anbieter_id) {
- db.all('SELECT id FROM tarife WHERE anbieter_id = ?', [anbieter_id], (err, tarifs) => {
- if (err) {
- db.run('ROLLBACK');
- res.status(500).json({ error: err.message });
-
- return;
- }
-
- // Insert tarif associations for all tarifs of the anbieter
- let completed = 0;
- const total = tarifs.length;
-
- if (total === 0) {
- db.run('COMMIT');
- res.json({ message: 'Tarifoption erfolgreich aktualisiert' });
-
- return;
- }
-
- tarifs.forEach(tarif => {
- db.run('INSERT INTO tarifoptionen_tarife (tarifoption_id, tarif_id) VALUES (?, ?)',
- [id, tarif.id], function(err) {
- completed++;
- if (err) {
- db.run('ROLLBACK');
- if (!res.headersSent) {
- res.status(500).json({ error: err.message });
- }
-
- return;
- }
-
- // Also insert into old structure
- db.run('INSERT OR IGNORE INTO tarifoptionen (anbieter_id, tarif_id, name, code, provision_euro, beschreibung) VALUES (?, ?, ?, ?, ?, ?)',
- [anbieter_id, tarif.id, name, code, provision_euro || 0, beschreibung], function(err) {
- if (err) {
- console.error('Error inserting into old tarifoptionen:', err);
- }
- });
-
- if (completed === total) {
- db.run('COMMIT');
- if (!res.headersSent) {
- res.json({ message: 'Tarifoption erfolgreich aktualisiert' });
- }
-
- }
- }
- );
- });
- });
- } else if (tarif_ids && tarif_ids.length > 0) {
- // Insert specific tarif associations
- let completed = 0;
- const total = tarif_ids.length;
-
- tarif_ids.forEach(tarifId => {
- db.run('INSERT INTO tarifoptionen_tarife (tarifoption_id, tarif_id) VALUES (?, ?)',
- [id, tarifId], function(err) {
- completed++;
- if (err) {
- db.run('ROLLBACK');
- if (!res.headersSent) {
- res.status(500).json({ error: err.message });
- }
-
- return;
- }
-
- // Also insert into old structure - need to get anbieter_id first
- db.get('SELECT anbieter_id FROM tarife WHERE id = ?', [tarifId], (err, tarif) => {
- if (!err && tarif) {
- db.run('INSERT OR IGNORE INTO tarifoptionen (anbieter_id, tarif_id, name, code, provision_euro, beschreibung) VALUES (?, ?, ?, ?, ?, ?)',
- [tarif.anbieter_id, tarifId, name, code, provision_euro || 0, beschreibung], function(err) {
- if (err) {
- console.error('Error inserting into old tarifoptionen:', err);
- }
- });
- }
- });
-
- if (completed === total) {
- db.run('COMMIT');
- if (!res.headersSent) {
- res.json({ message: 'Tarifoption erfolgreich aktualisiert' });
- }
-
- }
- }
- );
- });
- } else {
- db.run('COMMIT');
- res.json({ message: 'Tarifoption erfolgreich aktualisiert' });
-
- }
- });
- });
- });
-});
-
-// Update tarifoption
-app.put('/api/tarifoptionen/:id', (req, res) => {
- const { id } = req.params;
- const { name, provision_betrag } = req.body;
- const db = getDatabase();
-
- if (!name) {
- return res.status(400).json({ error: 'Tarifoption-Name ist erforderlich' });
- }
-
- const query = `
- UPDATE tarifoptionen
- SET name = ?, provision_betrag = ?
- WHERE id = ?
- `;
-
- db.run(query, [name, provision_betrag || null, id], function(err) {
- if (err) {
- if (err.message.includes('UNIQUE constraint failed')) {
- res.status(400).json({ error: 'Eine Tarifoption mit diesem Namen existiert bereits für diesen Tarif' });
- } else {
- res.status(500).json({ error: err.message });
- }
- return;
- }
- if (this.changes === 0) {
- res.status(404).json({ error: 'Tarifoption nicht gefunden' });
- return;
- }
- res.json({ message: 'Tarifoption erfolgreich aktualisiert' });
-
- });
-});
-
-// Delete tarifoption
-app.delete('/api/tarifoptionen/:id', (req, res) => {
- const id = req.params.id;
- const db = getDatabase();
-
- db.run('DELETE FROM tarifoptionen WHERE id = ?', [id], function(err) {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- if (this.changes === 0) {
- res.status(404).json({ error: 'Tarifoption nicht gefunden' });
- return;
- }
- res.json({ message: 'Tarifoption erfolgreich gelöscht' });
-
- });
-});
-
-// Mitarbeiter-Prozentsätze endpoints
-
-// Get mitarbeiter-prozentsaetze for specific anbieter
-app.get('/api/anbieter/:id/mitarbeiter-prozentsaetze', (req, res) => {
- const anbieterId = req.params.id;
- const db = getDatabase();
-
- const query = `
- SELECT * FROM mitarbeiter_prozentsaetze
- WHERE anbieter_id = ?
- ORDER BY gueltig_ab DESC
- `;
-
- db.all(query, [anbieterId], (err, rows) => {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- res.json(rows);
-
- });
-});
-
-// Add new mitarbeiter-prozentsatz
-app.post('/api/mitarbeiter-prozentsaetze', (req, res) => {
- const {
- anbieter_id,
- prozentsatz,
- gueltig_ab,
- beschreibung
- } = req.body;
- const db = getDatabase();
-
- const query = `
- INSERT INTO mitarbeiter_prozentsaetze (
- anbieter_id, prozentsatz, gueltig_ab, beschreibung
- )
- VALUES (?, ?, ?, ?)
- `;
-
- db.run(query, [
- anbieter_id,
- prozentsatz,
- gueltig_ab || new Date().toISOString().split('T')[0],
- beschreibung || null
- ], function(err) {
- if (err) {
- if (err.message.includes('UNIQUE constraint failed')) {
- res.status(400).json({ error: 'Ein Mitarbeiter-Prozentsatz für dieses Datum existiert bereits für diesen Anbieter' });
- } else {
- res.status(500).json({ error: err.message });
- }
- return;
- }
- res.json({
- id: this.lastID,
- message: 'Mitarbeiter-Prozentsatz erfolgreich erstellt'
- });
-
- });
-});
-
-// Delete mitarbeiter-prozentsatz
-app.delete('/api/mitarbeiter-prozentsaetze/:id', (req, res) => {
- const id = req.params.id;
- const db = getDatabase();
-
- db.run('DELETE FROM mitarbeiter_prozentsaetze WHERE id = ?', [id], function(err) {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- if (this.changes === 0) {
- res.status(404).json({ error: 'Mitarbeiter-Prozentsatz nicht gefunden' });
- return;
- }
- res.json({ message: 'Mitarbeiter-Prozentsatz erfolgreich gelöscht' });
-
- });
-});
-
-// Update mitarbeiter-provision for tarif
-app.put('/api/tarife/:id/mitarbeiter-provision', (req, res) => {
- const tarifId = req.params.id;
- const { mitarbeiter_provision } = req.body;
- const db = getDatabase();
-
- db.run('UPDATE tarife SET mitarbeiter_provision = ? WHERE id = ?',
- [mitarbeiter_provision || 0, tarifId],
- function(err) {
- if (err) {
- res.status(500).json({ error: err.message });
- return;
- }
- if (this.changes === 0) {
- res.status(404).json({ error: 'Tarif nicht gefunden' });
- return;
- }
- res.json({ message: 'Mitarbeiter-Provision erfolgreich aktualisiert' });
-
- });
-});
-
-// Serve main HTML file
-app.get('/', (req, res) => {
- res.sendFile(path.join(__dirname, 'public', 'index.html'));
+ console.error('Unhandled error:', err);
+ res.status(err && err.status ? err.status : 500).json({ error: err && err.message ? err.message : 'Interner Server-Fehler' });
});
app.listen(PORT, () => {
console.log(`Server läuft auf http://localhost:${PORT}`);
-});
\ No newline at end of file
+});
diff --git a/tests/provisionService.test.js b/tests/provisionService.test.js
new file mode 100644
index 0000000..551c036
--- /dev/null
+++ b/tests/provisionService.test.js
@@ -0,0 +1,138 @@
+const test = require('node:test');
+const assert = require('node:assert');
+const { calculate } = require('../provisionService');
+
+function baseInput(overrides = {}) {
+ return Object.assign({
+ schaltung: { hardware: null, hardware_ek: null, kundenverkaufspreis_netto: null },
+ tarif: { id: 1, name: 'Tarif X', provision_euro: 100, mitarbeiter_provision: 0, beschreibung: null },
+ tarifRule: null,
+ anbieterRule: null,
+ tarifoptionen: [],
+ mitarbeiterProzentsatz: 10,
+ }, overrides);
+}
+
+test('falls back to direct tariff commission when no rules exist', () => {
+ const r = calculate(baseInput());
+ assert.strictEqual(r.shop.betrag, 100);
+ assert.strictEqual(r.shop.quelle, 'Direkte Tarif-Provision');
+});
+
+test('tariff-specific fix rule wins over global and direct', () => {
+ const r = calculate(baseInput({
+ tarifRule: { provision_typ: 'fix', betrag: 50, basis_euro: null },
+ anbieterRule: { provision_typ: 'fix', betrag: 30, basis_euro: null },
+ }));
+ assert.strictEqual(r.shop.betrag, 50);
+ assert.strictEqual(r.shop.quelle, 'Tarif-spezifische Regel');
+});
+
+test('global anbieter rule applied when no tariff-specific rule', () => {
+ const r = calculate(baseInput({
+ tarifRule: null,
+ anbieterRule: { provision_typ: 'fix', betrag: 25, basis_euro: null },
+ }));
+ assert.strictEqual(r.shop.betrag, 25);
+ assert.strictEqual(r.shop.quelle, 'Globale Anbieter-Regel');
+});
+
+test('percent rule uses basis_euro as the base', () => {
+ const r = calculate(baseInput({
+ anbieterRule: { provision_typ: 'prozent', betrag: 12, basis_euro: 200 },
+ }));
+ assert.strictEqual(r.shop.betrag, 24);
+});
+
+test('percent rule with missing basis_euro yields 0', () => {
+ const r = calculate(baseInput({
+ anbieterRule: { provision_typ: 'prozent', betrag: 12, basis_euro: null },
+ }));
+ assert.strictEqual(r.shop.betrag, 0);
+});
+
+test('tarifoptionen are added (positive)', () => {
+ const r = calculate(baseInput({
+ tarifoptionen: [{ name: 'Bonus', provision_euro: 25 }],
+ }));
+ assert.strictEqual(r.shop.betrag, 125);
+ assert.strictEqual(r.tarifoptionen.betrag, 25);
+});
+
+test('tarifoptionen rebates (negative) reduce commission', () => {
+ const r = calculate(baseInput({
+ tarifoptionen: [{ name: 'Rabatt', provision_euro: -30 }],
+ }));
+ assert.strictEqual(r.shop.betrag, 70);
+});
+
+test('hardware adds (KVP - EK) to commission', () => {
+ const r = calculate(baseInput({
+ schaltung: { hardware: '123456789012345', hardware_ek: 40, kundenverkaufspreis_netto: 100 },
+ }));
+ assert.strictEqual(r.shop.betrag, 160); // 100 + (100 - 40)
+ assert.strictEqual(r.hardware_gewinn, 60);
+});
+
+test('hardware ignored when hardware string is empty', () => {
+ const r = calculate(baseInput({
+ schaltung: { hardware: '', hardware_ek: 40, kundenverkaufspreis_netto: 100 },
+ }));
+ assert.strictEqual(r.shop.betrag, 100);
+});
+
+test('employee fix rule wins regardless of percent fallback', () => {
+ const r = calculate(baseInput({
+ tarifRule: {
+ provision_typ: 'fix', betrag: 100, basis_euro: null,
+ verkaeufer_provision_typ: 'fix', verkaeufer_provision_betrag: 15,
+ },
+ }));
+ assert.strictEqual(r.shop.betrag, 100);
+ assert.strictEqual(r.mitarbeiter.betrag, 15);
+});
+
+test('employee percent rule uses shop commission as base', () => {
+ const r = calculate(baseInput({
+ tarifRule: {
+ provision_typ: 'fix', betrag: 200, basis_euro: null,
+ verkaeufer_provision_typ: 'prozent', verkaeufer_provision_betrag: 10,
+ },
+ }));
+ assert.strictEqual(r.shop.betrag, 200);
+ assert.strictEqual(r.mitarbeiter.betrag, 20);
+});
+
+test('employee falls back to anbieter percentage when rule has no employee fields', () => {
+ const r = calculate(baseInput({
+ tarif: { provision_euro: 100 },
+ mitarbeiterProzentsatz: 8,
+ }));
+ assert.strictEqual(r.shop.betrag, 100);
+ assert.strictEqual(r.mitarbeiter.betrag, 8); // 8% of 100
+});
+
+test('rounding produces 2-decimal numbers', () => {
+ const r = calculate(baseInput({
+ anbieterRule: { provision_typ: 'prozent', betrag: 33.33, basis_euro: 100 },
+ }));
+ assert.strictEqual(r.shop.betrag, 33.33);
+});
+
+test('full scenario: tariff rule + tarifoptionen + hardware + employee %', () => {
+ const r = calculate({
+ schaltung: { hardware: '111111111111111', hardware_ek: 50, kundenverkaufspreis_netto: 80 },
+ tarif: { provision_euro: 0 },
+ tarifRule: {
+ provision_typ: 'fix', betrag: 200, basis_euro: null,
+ verkaeufer_provision_typ: 'prozent', verkaeufer_provision_betrag: 10,
+ },
+ anbieterRule: null,
+ tarifoptionen: [{ name: 'Aktion', provision_euro: 25 }, { name: 'Rabatt', provision_euro: -45 }],
+ mitarbeiterProzentsatz: 0,
+ });
+ // shop = 200 + (25 - 45) + (80 - 50) = 200 - 20 + 30 = 210
+ assert.strictEqual(r.shop.betrag, 210);
+ // employee = 10% of 210 = 21
+ assert.strictEqual(r.mitarbeiter.betrag, 21);
+});