Major refactor: auth, bug fixes, security hardening, modular routes
Bug fixes - Sync database.js schema with actual production DB (tarifoptionen shape was wrong; new installs would have broken). - Fix SQL syntax error `SELECT to.*` (reserved keyword) in single- schaltung provision endpoint, plus the dead fallback hierarchy that selected columns that were never in the query. - Fix PUT /api/tarifoptionen/:id writing to non-existent column provision_betrag (should be provision_euro). - Fix race condition in PUT /api/schaltungen/:id where forEach with async db.run inside a transaction could send duplicate responses. - Delete dokumente rows explicitly before deleting schaltung (existing DBs lack ON DELETE CASCADE on dokumente FK). - Replace hardcoded basisBetrag=100 for percent rules; basis is now configurable per rule via the new basis_euro column. Security - Multi-user authentication: users table, bcryptjs, express-session with SQLite store, login/logout/me/setup/change-password endpoints. - Auth gate on all /api/* except /api/auth/*. - CLI to create users: npm run create-user -- <user> <pass> [--admin]. - helmet for security headers. - CORS restricted to ALLOWED_ORIGINS env (default same-origin). - Rate limits: 300/min global, 20/15min on login. - Path-traversal validation on document download/delete. - XSS hardening in frontend: escapeHtml helper applied to all DB-derived innerHTML interpolations; inline onclick handlers that embedded user-controlled strings replaced with data-* attrs + addEventListener. Architecture - Provision calculation centralised in provisionService.js as the single source of truth (was duplicated and inconsistent). - server.js cut from 2138 to ~140 lines; routes split into per- resource modules under routes/. - Promise wrapper db.js for sqlite3 callbacks; async/await in routes. - middleware/ for auth and async error handling. Tests - 14 unit tests for provisionService covering fallback hierarchy, percent calculation, tarifoptionen, hardware logic, employee commission. All green. Config - dotenv loads .env.local at startup (gitignored). - .gitignore extended for sessions.db and .env*. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
55ec916f35
commit
4303b00b9b
23 files changed
+2427
-2575
No files matched your search
@@ -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;
|
||||
+120
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user