diff --git a/.gitignore b/.gitignore index 10ee19c..e4baee2 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,12 @@ uploads/ # Local Claude settings .claude/ +# Local environment / secrets +.env +.env.local +.env.*.local +sessions.db + # OS / Editor .DS_Store Thumbs.db diff --git a/database.js b/database.js index 9dfb99f..3c9d037 100644 --- a/database.js +++ b/database.js @@ -3,10 +3,8 @@ const path = require('path'); const dbPath = path.join(__dirname, 'provision.db'); -// Singleton database instance let dbInstance = null; -// Get or create database instance (Singleton pattern) function getDatabase() { if (!dbInstance) { dbInstance = new sqlite3.Database(dbPath, (err) => { @@ -18,13 +16,11 @@ function getDatabase() { console.log('Connected to SQLite database'); }); - // Set busy timeout to handle concurrent access dbInstance.configure('busyTimeout', 3000); } return dbInstance; } -// Enable foreign keys for a database connection function enableForeignKeys(db) { db.run('PRAGMA foreign_keys = ON', (err) => { if (err) { @@ -33,7 +29,6 @@ function enableForeignKeys(db) { }); } -// Close database connection (for graceful shutdown) function closeDatabase() { if (dbInstance) { dbInstance.close((err) => { @@ -47,18 +42,27 @@ function closeDatabase() { } } +// Best-effort ALTER TABLE: ignores "duplicate column" errors but logs others +function addColumnIfMissing(db, table, columnDef) { + db.run(`ALTER TABLE ${table} ADD COLUMN ${columnDef}`, (err) => { + if (err && !/duplicate column name/i.test(err.message)) { + console.error(`ALTER TABLE ${table} ADD ${columnDef}:`, err.message); + } + }); +} + function initDatabase() { const db = getDatabase(); db.serialize(() => { - // Anbieter (Providers) table + // Anbieter (Providers) db.run(`CREATE TABLE IF NOT EXISTS anbieter ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); - - // Mitarbeiter-Prozentsätze pro Anbieter (can be changed retrospectively) + + // Mitarbeiter-Prozentsätze pro Anbieter db.run(`CREATE TABLE IF NOT EXISTS mitarbeiter_prozentsaetze ( id INTEGER PRIMARY KEY AUTOINCREMENT, anbieter_id INTEGER NOT NULL, @@ -69,8 +73,8 @@ function initDatabase() { FOREIGN KEY (anbieter_id) REFERENCES anbieter (id), UNIQUE(anbieter_id, gueltig_ab) )`); - - // Tarife (Tariffs) table - simplified with fixed euro amounts only + + // Tarife db.run(`CREATE TABLE IF NOT EXISTS tarife ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -82,18 +86,15 @@ function initDatabase() { FOREIGN KEY (anbieter_id) REFERENCES anbieter (id), UNIQUE(name, anbieter_id) )`); - - // Add mitarbeiter_provision column if it doesn't exist (for existing databases) - db.run(`ALTER TABLE tarife ADD COLUMN mitarbeiter_provision DECIMAL(10,2) DEFAULT 0`, (err) => { - // Ignore error if column already exists - }); - - // Provision rules table (tariff-specific) + addColumnIfMissing(db, 'tarife', 'mitarbeiter_provision DECIMAL(10,2) DEFAULT 0'); + + // Tariff-specific commission rules. basis_euro: base amount for percent calculation. db.run(`CREATE TABLE IF NOT EXISTS provisionsregeln ( id INTEGER PRIMARY KEY AUTOINCREMENT, tarif_id INTEGER NOT NULL, provision_typ TEXT NOT NULL CHECK(provision_typ IN ('fix', 'prozent')), betrag DECIMAL(10,2) NOT NULL, + basis_euro DECIMAL(10,2), beschreibung TEXT, verkaeufer_provision_typ TEXT CHECK(verkaeufer_provision_typ IN ('fix', 'prozent')), verkaeufer_provision_betrag DECIMAL(10,2), @@ -103,13 +104,15 @@ function initDatabase() { created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (tarif_id) REFERENCES tarife (id) )`); - - // Global provider commission rules table + addColumnIfMissing(db, 'provisionsregeln', 'basis_euro DECIMAL(10,2)'); + + // Global provider commission rules db.run(`CREATE TABLE IF NOT EXISTS anbieter_provisionsregeln ( id INTEGER PRIMARY KEY AUTOINCREMENT, anbieter_id INTEGER NOT NULL, provision_typ TEXT NOT NULL CHECK(provision_typ IN ('fix', 'prozent')), betrag DECIMAL(10,2) NOT NULL, + basis_euro DECIMAL(10,2), beschreibung TEXT, verkaeufer_provision_typ TEXT CHECK(verkaeufer_provision_typ IN ('fix', 'prozent')), verkaeufer_provision_betrag DECIMAL(10,2), @@ -119,8 +122,9 @@ function initDatabase() { created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (anbieter_id) REFERENCES anbieter (id) )`); - - // Schaltungen (Connections/Commissions) table + addColumnIfMissing(db, 'anbieter_provisionsregeln', 'basis_euro DECIMAL(10,2)'); + + // Schaltungen db.run(`CREATE TABLE IF NOT EXISTS schaltungen ( id INTEGER PRIMARY KEY AUTOINCREMENT, schaltungs_id TEXT NOT NULL UNIQUE, @@ -139,16 +143,11 @@ function initDatabase() { FOREIGN KEY (anbieter_id) REFERENCES anbieter (id), FOREIGN KEY (tarif_id) REFERENCES tarife (id) )`); + addColumnIfMissing(db, 'schaltungen', 'hardware_ek DECIMAL(10,2)'); + addColumnIfMissing(db, 'schaltungen', 'kundenverkaufspreis_netto DECIMAL(10,2)'); - // Add new hardware pricing columns if they don't exist (for existing databases) - db.run(`ALTER TABLE schaltungen ADD COLUMN hardware_ek DECIMAL(10,2)`, (err) => { - // Ignore error if column already exists - }); - db.run(`ALTER TABLE schaltungen ADD COLUMN kundenverkaufspreis_netto DECIMAL(10,2)`, (err) => { - // Ignore error if column already exists - }); - - // Dokumente (Documents) table + // Dokumente (note: ON DELETE CASCADE only applied to new DBs - existing DBs + // handled via explicit DELETE in server.js) db.run(`CREATE TABLE IF NOT EXISTS dokumente ( id INTEGER PRIMARY KEY AUTOINCREMENT, schaltung_id INTEGER NOT NULL, @@ -160,8 +159,8 @@ function initDatabase() { uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (schaltung_id) REFERENCES schaltungen (id) ON DELETE CASCADE )`); - - // Deleted Schaltungen tracking table + + // Soft-deleted schaltungen db.run(`CREATE TABLE IF NOT EXISTS geloeschte_schaltungen ( id INTEGER PRIMARY KEY AUTOINCREMENT, original_id INTEGER NOT NULL, @@ -181,39 +180,25 @@ function initDatabase() { geloescht_am DATETIME DEFAULT CURRENT_TIMESTAMP, geloescht_von TEXT DEFAULT 'System' )`); - - // Insert some default providers and tariffs for testing - db.run(`INSERT OR IGNORE INTO anbieter (name) VALUES - ('Telekom'), - ('Vodafone'), - ('O2'), - ('1&1')`); - - db.run(`INSERT OR IGNORE INTO tarife (name, anbieter_id, provision_euro, beschreibung) VALUES - ('MagentaMobil S', 1, 150.00, 'Telekom Basis-Tarif'), - ('MagentaMobil M', 1, 200.00, 'Telekom Standard-Tarif'), - ('MagentaMobil L', 1, 250.00, 'Telekom Premium-Tarif'), - ('VF green LTE 10GB mit Smartphone 5', 2, 190.00, 'Vodafone Standard-Paket'), - ('Red S', 2, 120.00, 'Vodafone Einsteiger-Tarif'), - ('Red M', 2, 160.00, 'Vodafone Standard-Tarif'), - ('Red L', 2, 200.00, 'Vodafone Premium-Tarif'), - ('o2 Free S', 3, 100.00, 'O2 Basis-Tarif'), - ('o2 Free M', 3, 140.00, 'O2 Standard-Tarif'), - ('o2 Free L', 3, 180.00, 'O2 Premium-Tarif')`); - - // Insert mitarbeiter prozentsätze for each provider - db.run(`INSERT OR IGNORE INTO mitarbeiter_prozentsaetze (anbieter_id, prozentsatz, beschreibung) VALUES - (1, 8.00, 'Telekom Mitarbeiter-Provision'), - (2, 10.00, 'Vodafone/Freenet Mitarbeiter-Provision'), - (3, 7.50, 'O2 Mitarbeiter-Provision'), - (4, 9.00, '1&1 Mitarbeiter-Provision')`); - - // Insert some example global provider commission rules - db.run(`INSERT OR IGNORE INTO anbieter_provisionsregeln (anbieter_id, provision_typ, betrag, beschreibung) VALUES - (3, 'fix', 20.00, 'Standard O2 Provision'), - (4, 'prozent', 12.00, '12% Standard 1&1 Provision')`); - - // Tarifoptionen - updated structure for multiple tarif support + + // Tarifoptionen - schema matches production: anbieter_id required, tarif_id optional + // (NULL = anbieter-wide), unique per (anbieter_id, tarif_id, name). + db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + anbieter_id INTEGER NOT NULL, + tarif_id INTEGER, + name TEXT NOT NULL, + code TEXT, + provision_euro DECIMAL(10,2) DEFAULT 0, + beschreibung TEXT, + aktiv BOOLEAN DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (anbieter_id) REFERENCES anbieter (id), + FOREIGN KEY (tarif_id) REFERENCES tarife (id), + UNIQUE(anbieter_id, tarif_id, name) + )`); + + // M:N variant kept for the /multi endpoints db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, @@ -224,7 +209,6 @@ function initDatabase() { created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); - // Many-to-many relationship between tarifoptionen and tarife db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen_tarife ( id INTEGER PRIMARY KEY AUTOINCREMENT, tarifoption_id INTEGER NOT NULL, @@ -235,21 +219,7 @@ function initDatabase() { UNIQUE(tarifoption_id, tarif_id) )`); - // Keep old table for migration compatibility - db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - tarif_id INTEGER NOT NULL, - name TEXT NOT NULL, - code TEXT, - provision_euro DECIMAL(10,2) DEFAULT 0, - beschreibung TEXT, - aktiv BOOLEAN DEFAULT 1, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (tarif_id) REFERENCES tarife (id), - UNIQUE(tarif_id, name) - )`); - - // Schaltungen_Tarifoptionen (Many-to-Many relationship) + // Link table: which tarifoptionen are assigned to which schaltung db.run(`CREATE TABLE IF NOT EXISTS schaltungen_tarifoptionen ( id INTEGER PRIMARY KEY AUTOINCREMENT, schaltung_id INTEGER NOT NULL, @@ -259,75 +229,61 @@ function initDatabase() { FOREIGN KEY (tarifoption_id) REFERENCES tarifoptionen (id), UNIQUE(schaltung_id, tarifoption_id) )`); - - // Insert example tarifoptionen with simplified euro amounts (old format) - db.run(`INSERT OR IGNORE INTO tarifoptionen (tarif_id, name, code, provision_euro, beschreibung) VALUES - (1, 'Magenta Premium Option', 'MAG-PREM', 50.00, 'Premium-Zusatz für Telekom'), - (1, 'Aktionscode Neukunde', 'NEU2024', 30.00, 'Neukunden-Bonus'), - (4, '24 x 17€ MGP Rabatt GK A3958457', 'A3958457', -120.00, 'Vodafone Rabatt-Aktion'), - (5, 'Red M Aktion Q1', 'RED-M-Q1', 25.00, 'Quartals-Aktion'), - (6, 'Red L Premium Paket', 'RED-L-PREM', 35.00, 'Premium-Paket')`); - // Migration function to move data from old to new structure - function migrateToNewTarifoptionen() { - db.serialize(() => { - // Check if migration is needed - db.get(`SELECT COUNT(*) as count FROM tarifoptionen_new`, (err, row) => { - if (err || row.count > 0) return; // Already migrated or error + // Users for authentication + db.run(`CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`); - console.log('Migrating tarifoptionen to new structure...'); + // Seed data (idempotent) + db.run(`INSERT OR IGNORE INTO anbieter (name) VALUES + ('Telekom'), + ('Vodafone'), + ('O2'), + ('1&1')`); - // Get all unique tarifoptionen from old table - db.all(`SELECT DISTINCT name, code, provision_euro, beschreibung, aktiv, created_at FROM tarifoptionen`, (err, oldOptions) => { - if (err || !oldOptions.length) return; + db.run(`INSERT OR IGNORE INTO tarife (name, anbieter_id, provision_euro, beschreibung) VALUES + ('MagentaMobil S', 1, 150.00, 'Telekom Basis-Tarif'), + ('MagentaMobil M', 1, 200.00, 'Telekom Standard-Tarif'), + ('MagentaMobil L', 1, 250.00, 'Telekom Premium-Tarif'), + ('VF green LTE 10GB mit Smartphone 5', 2, 190.00, 'Vodafone Standard-Paket'), + ('Red S', 2, 120.00, 'Vodafone Einsteiger-Tarif'), + ('Red M', 2, 160.00, 'Vodafone Standard-Tarif'), + ('Red L', 2, 200.00, 'Vodafone Premium-Tarif'), + ('o2 Free S', 3, 100.00, 'O2 Basis-Tarif'), + ('o2 Free M', 3, 140.00, 'O2 Standard-Tarif'), + ('o2 Free L', 3, 180.00, 'O2 Premium-Tarif')`); - oldOptions.forEach(option => { - // Insert unique tarifoption into new table - db.run(`INSERT OR IGNORE INTO tarifoptionen_new (name, code, provision_euro, beschreibung, aktiv, created_at) - VALUES (?, ?, ?, ?, ?, ?)`, - [option.name, option.code, option.provision_euro, option.beschreibung, option.aktiv, option.created_at], - function(err) { - if (err) return; + db.run(`INSERT OR IGNORE INTO mitarbeiter_prozentsaetze (anbieter_id, prozentsatz, beschreibung) VALUES + (1, 8.00, 'Telekom Mitarbeiter-Provision'), + (2, 10.00, 'Vodafone/Freenet Mitarbeiter-Provision'), + (3, 7.50, 'O2 Mitarbeiter-Provision'), + (4, 9.00, '1&1 Mitarbeiter-Provision')`); - const newTarifoptionId = this.lastID; + db.run(`INSERT OR IGNORE INTO anbieter_provisionsregeln (anbieter_id, provision_typ, betrag, beschreibung) VALUES + (3, 'fix', 20.00, 'Standard O2 Provision'), + (4, 'prozent', 12.00, '12% Standard 1&1 Provision')`); - // Link this tarifoption to all tarifs it was originally assigned to - db.all(`SELECT tarif_id FROM tarifoptionen WHERE name = ? AND code = ?`, - [option.name, option.code], - (err, tarifs) => { - if (err) return; + // Seed tarifoptionen (correct schema: anbieter_id required) + db.run(`INSERT OR IGNORE INTO tarifoptionen (anbieter_id, tarif_id, name, code, provision_euro, beschreibung) VALUES + (1, 1, 'Magenta Premium Option', 'MAG-PREM', 50.00, 'Premium-Zusatz für Telekom'), + (1, 1, 'Aktionscode Neukunde', 'NEU2024', 30.00, 'Neukunden-Bonus'), + (2, 4, '24 x 17€ MGP Rabatt GK A3958457', 'A3958457', -120.00, 'Vodafone Rabatt-Aktion'), + (2, 5, 'Red M Aktion Q1', 'RED-M-Q1', 25.00, 'Quartals-Aktion'), + (2, 6, 'Red L Premium Paket', 'RED-L-PREM', 35.00, 'Premium-Paket')`); - tarifs.forEach(tarif => { - db.run(`INSERT OR IGNORE INTO tarifoptionen_tarife (tarifoption_id, tarif_id) VALUES (?, ?)`, - [newTarifoptionId, tarif.tarif_id]); - }); - }); - }); - }); - }); - }); - }); - } - - // Run migration only if not already done - db.get(`SELECT COUNT(*) as count FROM tarifoptionen_new`, (err, row) => { - if (!err && row && row.count === 0) { - migrateToNewTarifoptionen(); - } - }); - - // Keep existing individual tariff rules for backward compatibility db.run(`INSERT OR IGNORE INTO provisionsregeln (tarif_id, provision_typ, betrag, beschreibung) VALUES (1, 'fix', 25.00, 'Feste Provision für MagentaMobil S'), (2, 'fix', 35.00, 'Feste Provision für MagentaMobil M'), - (3, 'fix', 45.00, 'Feste Provision für MagentaMobil L'), - (4, 'prozent', 15.00, '15% Provision für Red S'), - (5, 'prozent', 18.00, '18% Provision für Red M'), - (6, 'prozent', 20.00, '20% Provision für Red L')`); + (3, 'fix', 45.00, 'Feste Provision für MagentaMobil L')`); }); - // Enable foreign keys AFTER all tables and initial data are created enableForeignKeys(db); } -module.exports = { initDatabase, getDatabase, closeDatabase }; \ No newline at end of file +module.exports = { initDatabase, getDatabase, closeDatabase }; diff --git a/db.js b/db.js new file mode 100644 index 0000000..ca1a89f --- /dev/null +++ b/db.js @@ -0,0 +1,44 @@ +// Promise wrappers around sqlite3's callback API. Routes use these instead of raw callbacks. +const { getDatabase } = require('./database'); + +function all(sql, params = []) { + return new Promise((resolve, reject) => { + getDatabase().all(sql, params, (err, rows) => { + if (err) reject(err); + else resolve(rows); + }); + }); +} + +function get(sql, params = []) { + return new Promise((resolve, reject) => { + getDatabase().get(sql, params, (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); +} + +function run(sql, params = []) { + return new Promise((resolve, reject) => { + getDatabase().run(sql, params, function (err) { + if (err) reject(err); + else resolve({ lastID: this.lastID, changes: this.changes }); + }); + }); +} + +// Executes work() inside BEGIN/COMMIT. Rolls back if work() throws. +async function transaction(work) { + await run('BEGIN'); + try { + const result = await work(); + await run('COMMIT'); + return result; + } catch (err) { + try { await run('ROLLBACK'); } catch (_) { /* swallow */ } + throw err; + } +} + +module.exports = { all, get, run, transaction }; diff --git a/middleware/asyncHandler.js b/middleware/asyncHandler.js new file mode 100644 index 0000000..c88f342 --- /dev/null +++ b/middleware/asyncHandler.js @@ -0,0 +1,5 @@ +// Wraps an async route handler so thrown errors flow to Express's error middleware +// instead of becoming unhandled rejections. +module.exports = function asyncHandler(fn) { + return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); +}; diff --git a/middleware/auth.js b/middleware/auth.js new file mode 100644 index 0000000..02f941b --- /dev/null +++ b/middleware/auth.js @@ -0,0 +1,17 @@ +// Auth middleware: requireLogin gates API routes, requireAdmin restricts admin actions. + +function requireLogin(req, res, next) { + if (req.session && req.session.userId) { + return next(); + } + return res.status(401).json({ error: 'Nicht angemeldet' }); +} + +function requireAdmin(req, res, next) { + if (req.session && req.session.userId && req.session.isAdmin) { + return next(); + } + return res.status(403).json({ error: 'Admin-Rechte erforderlich' }); +} + +module.exports = { requireLogin, requireAdmin }; diff --git a/package-lock.json b/package-lock.json index 0f093e1..4f73347 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,14 @@ "name": "provision-tracker", "version": "1.0.0", "dependencies": { + "bcryptjs": "^2.4.3", + "connect-sqlite3": "^0.9.13", "cors": "^2.8.5", + "dotenv": "^17.4.2", "express": "^4.18.2", + "express-rate-limit": "^7.4.0", + "express-session": "^1.18.0", + "helmet": "^7.1.0", "multer": "^1.4.5-lts.1", "sqlite3": "^5.1.6" }, @@ -237,6 +243,11 @@ } ] }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -510,6 +521,17 @@ "typedarray": "^0.0.6" } }, + "node_modules/connect-sqlite3": { + "version": "0.9.16", + "resolved": "https://registry.npmjs.org/connect-sqlite3/-/connect-sqlite3-0.9.16.tgz", + "integrity": "sha512-2gqo0QmcBBL8p8+eqpBETn7RgM/PaoKvpQGl8PfjEgwlr0VuMYNMxRJRrRCo3KR3fxMYeSsCw2tGNG0JKN9Nvg==", + "dependencies": { + "sqlite3": "^5.0.2" + }, + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -626,6 +648,17 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -795,6 +828,55 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "dependencies": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-session/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -1036,6 +1118,14 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -1738,6 +1828,14 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1885,6 +1983,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2509,6 +2615,17 @@ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", diff --git a/package.json b/package.json index 04010d0..b59fae8 100644 --- a/package.json +++ b/package.json @@ -5,15 +5,23 @@ "main": "server.js", "scripts": { "start": "node server.js", - "dev": "nodemon server.js" + "dev": "nodemon server.js", + "test": "node --test tests/", + "create-user": "node scripts/create-user.js" }, "dependencies": { + "bcryptjs": "^2.4.3", + "connect-sqlite3": "^0.9.13", + "cors": "^2.8.5", + "dotenv": "^17.4.2", "express": "^4.18.2", - "sqlite3": "^5.1.6", + "express-rate-limit": "^7.4.0", + "express-session": "^1.18.0", + "helmet": "^7.1.0", "multer": "^1.4.5-lts.1", - "cors": "^2.8.5" + "sqlite3": "^5.1.6" }, "devDependencies": { "nodemon": "^3.0.1" } -} \ No newline at end of file +} diff --git a/provisionService.js b/provisionService.js index a514440..82fb3da 100644 --- a/provisionService.js +++ b/provisionService.js @@ -1,184 +1,152 @@ -/** - * Commission Calculation Service - * - * This service handles all commission calculations for schaltungen. - * It implements a fallback hierarchy for determining commission rates: - * 1. Direct tariff commission (highest priority) - * 2. Tariff-specific rules - * 3. Global provider rules (fallback) - * 4. Tariff options (additive) - */ +// Commission calculation service. Single source of truth used by all provision endpoints. +// +// Hierarchy for shop commission (highest priority wins): +// 1. Tariff-specific rule (provisionsregeln, active, gueltig_ab <= schaltungs_datum) +// 2. Global provider rule (anbieter_provisionsregeln, active, gueltig_ab <= schaltungs_datum) +// 3. Direct tariff amount (tarife.provision_euro) +// +// For type='prozent' the rule's basis_euro field is the base amount the percentage +// applies to. If basis_euro is missing on a percent rule, the contribution is 0 +// (rule is effectively unusable - caller should validate at write time). +// +// Tarifoptionen are added on top (signed: rebates are negative). +// Hardware logic: when hardware is set, shop = shop - hardware_ek + kundenverkaufspreis_netto. +// +// Employee commission follows the same hierarchy but uses verkaeufer_* fields. +// For type='prozent' the base is the computed shop commission. -/** - * Calculate shop commission based on hierarchy - * @param {Object} schaltungData - Data from database including tariff and provider info - * @param {Array} tarifoptionen - Selected tariff options for this schaltung - * @returns {Object} Calculation result with amount, type, description, and source - */ -function calculateShopProvision(schaltungData, tarifoptionen) { - let provision_typ = null; - let provision_betrag = null; - let provision_beschreibung = null; - let berechnete_provision = 0; - let quelle = 'Keine Shop-Provision definiert'; - - // Priority 1: Direct tariff commission - if (schaltungData.tarif_provision_typ && schaltungData.tarif_provision_betrag) { - provision_typ = schaltungData.tarif_provision_typ; - provision_betrag = schaltungData.tarif_provision_betrag; - provision_beschreibung = schaltungData.tarif_provision_beschreibung; - quelle = 'Direkte Tarif-Provision'; - } - // Priority 2: Tariff-specific rule - else if (schaltungData.regel_provision_typ && schaltungData.regel_provision_betrag) { - provision_typ = schaltungData.regel_provision_typ; - provision_betrag = schaltungData.regel_provision_betrag; - provision_beschreibung = schaltungData.regel_provision_beschreibung; - quelle = 'Tarif-spezifische Regel'; - } - // Priority 3: Global provider rule as fallback - else if (schaltungData.global_provision_typ && schaltungData.global_provision_betrag) { - provision_typ = schaltungData.global_provision_typ; - provision_betrag = schaltungData.global_provision_betrag; - provision_beschreibung = schaltungData.global_provision_beschreibung; - quelle = 'Globale Anbieter-Regel'; - } - - // Calculate base commission - if (provision_typ && provision_betrag) { - if (provision_typ === 'fix') { - berechnete_provision = parseFloat(provision_betrag); - } else if (provision_typ === 'prozent') { - const basisBetrag = 100; // This could come from tariff table - berechnete_provision = (parseFloat(provision_betrag) / 100) * basisBetrag; - } - } - - // Add tarifoptionen to 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); - } - }); - } - - berechnete_provision += tarifoptionen_provision; - - // Update description if tarifoptionen were added - if (tarifoptionen_provision > 0) { - const tarifoptionen_text = verwendete_tarifoptionen.join(', '); - provision_beschreibung = provision_beschreibung - ? `${provision_beschreibung} + Tarifoptionen: ${tarifoptionen_text}` - : `Tarifoptionen: ${tarifoptionen_text}`; - } - - return { - typ: provision_typ, - betrag: provision_betrag, - beschreibung: provision_beschreibung, - berechnete_provision: berechnete_provision, - quelle: quelle, - tarifoptionen_provision: tarifoptionen_provision - }; +function num(value) { + const n = parseFloat(value); + return Number.isFinite(n) ? n : 0; } -/** - * Calculate employee commission based on hierarchy - * @param {Object} schaltungData - Data from database including tariff and provider info - * @param {number} shopProvision - Calculated shop commission (for percentage calculations) - * @returns {Object} Calculation result with amount, type, description, and source - */ -function calculateMitarbeiterProvision(schaltungData, shopProvision) { - let provision_typ = null; - let provision_betrag = null; - let provision_beschreibung = null; - let berechnete_provision = 0; - let quelle = 'Keine Mitarbeiter-Provision definiert'; +function pickRule(context) { + // Returns the active rule that should apply, or null. + // context: { tarifRule, anbieterRule, tarif } + if (context.tarifRule) { + return { source: 'Tarif-spezifische Regel', rule: context.tarifRule }; + } + if (context.anbieterRule) { + return { source: 'Globale Anbieter-Regel', rule: context.anbieterRule }; + } + if (context.tarif && context.tarif.provision_euro != null && num(context.tarif.provision_euro) !== 0) { + return { + source: 'Direkte Tarif-Provision', + rule: { + provision_typ: 'fix', + betrag: num(context.tarif.provision_euro), + basis_euro: null, + beschreibung: context.tarif.beschreibung || null, + verkaeufer_provision_typ: null, + verkaeufer_provision_betrag: null, + verkaeufer_provision_beschreibung: null, + }, + }; + } + return null; +} - // Priority 1: Direct tariff employee commission - if (schaltungData.tarif_verkaeufer_provision_typ && schaltungData.tarif_verkaeufer_provision_betrag) { - provision_typ = schaltungData.tarif_verkaeufer_provision_typ; - provision_betrag = schaltungData.tarif_verkaeufer_provision_betrag; - provision_beschreibung = schaltungData.tarif_verkaeufer_provision_beschreibung; - quelle = 'Direkte Tarif-Verkäufer-Provision'; +function applyShopRule(rule) { + if (!rule) return 0; + if (rule.provision_typ === 'fix') { + return num(rule.betrag); } - // Priority 2: Tariff-specific rule employee commission - else if (schaltungData.regel_verkaeufer_provision_typ && schaltungData.regel_verkaeufer_provision_betrag) { - provision_typ = schaltungData.regel_verkaeufer_provision_typ; - provision_betrag = schaltungData.regel_verkaeufer_provision_betrag; - provision_beschreibung = schaltungData.regel_verkaeufer_provision_beschreibung; - quelle = 'Tarif-spezifische Verkäufer-Regel'; + if (rule.provision_typ === 'prozent') { + const basis = num(rule.basis_euro); + return (num(rule.betrag) / 100) * basis; } - // Priority 3: Global provider rule employee commission - else if (schaltungData.global_verkaeufer_provision_typ && schaltungData.global_verkaeufer_provision_betrag) { - provision_typ = schaltungData.global_verkaeufer_provision_typ; - provision_betrag = schaltungData.global_verkaeufer_provision_betrag; - provision_beschreibung = schaltungData.global_verkaeufer_provision_beschreibung; - quelle = 'Globale Anbieter-Verkäufer-Regel'; + return 0; +} + +function applyEmployeeRule(rule, shopProvision) { + if (!rule) return 0; + if (rule.verkaeufer_provision_typ === 'fix') { + return num(rule.verkaeufer_provision_betrag); + } + if (rule.verkaeufer_provision_typ === 'prozent') { + return (num(rule.verkaeufer_provision_betrag) / 100) * shopProvision; + } + return 0; +} + +function sumTarifoptionen(tarifoptionen) { + if (!Array.isArray(tarifoptionen)) return { sum: 0, names: [] }; + let sum = 0; + const names = []; + for (const opt of tarifoptionen) { + const v = num(opt.provision_euro); + if (v !== 0) { + sum += v; + names.push(opt.name); + } + } + return { sum, names }; +} + +// Main entry. Inputs are plain data (already loaded from DB by the route). +// `schaltung` is the schaltungen row (joined with tarif basics). +// `tarif` is the full tarif row. +// `tarifRule` is the active provisionsregeln row (or null). +// `anbieterRule` is the active anbieter_provisionsregeln row (or null). +// `tarifoptionen` is an array of tarifoption rows linked to this schaltung. +// `mitarbeiterProzentsatz` is a number (0-100) — used only when there is no +// employee-specific rule and the tariff defines mitarbeiter_provision. +function calculate({ schaltung, tarif, tarifRule, anbieterRule, tarifoptionen, mitarbeiterProzentsatz }) { + const picked = pickRule({ tarifRule, anbieterRule, tarif }); + const ruleSource = picked ? picked.source : 'Keine Shop-Provision definiert'; + const rule = picked ? picked.rule : null; + + let shopProvision = applyShopRule(rule); + + const { sum: tarifoptionenSum, names: tarifoptionenNames } = sumTarifoptionen(tarifoptionen); + shopProvision += tarifoptionenSum; + + let hardwareGewinn = 0; + if (schaltung && schaltung.hardware && String(schaltung.hardware).trim() !== '') { + hardwareGewinn = num(schaltung.kundenverkaufspreis_netto) - num(schaltung.hardware_ek); + shopProvision += hardwareGewinn; } - // Calculate employee commission - if (provision_typ && provision_betrag) { - if (provision_typ === 'fix') { - berechnete_provision = parseFloat(provision_betrag); - } else if (provision_typ === 'prozent') { - // If percentage, calculate based on shop commission - berechnete_provision = (parseFloat(provision_betrag) / 100) * shopProvision; + let employeeProvision = applyEmployeeRule(rule, shopProvision); + let employeeSource = picked ? `${picked.source} (Verkäufer)` : 'Keine Mitarbeiter-Provision definiert'; + + // Fallback: if no rule defines an employee commission, use the per-anbieter + // mitarbeiter_prozentsatz against the computed shop commission. + if (employeeProvision === 0 && (!rule || !rule.verkaeufer_provision_typ)) { + const p = num(mitarbeiterProzentsatz); + if (p !== 0) { + employeeProvision = (p / 100) * shopProvision; + employeeSource = `${p}% Mitarbeiter-Anteil von ${shopProvision.toFixed(2)}€`; } } return { - typ: provision_typ, - betrag: provision_betrag, - beschreibung: provision_beschreibung, - berechnete_provision: berechnete_provision, - quelle: quelle + shop: { + betrag: round2(shopProvision), + quelle: ruleSource, + typ: rule ? rule.provision_typ : null, + regel_betrag: rule ? num(rule.betrag) : null, + basis_euro: rule ? (rule.basis_euro != null ? num(rule.basis_euro) : null) : null, + beschreibung: rule ? rule.beschreibung : null, + }, + mitarbeiter: { + betrag: round2(employeeProvision), + quelle: employeeSource, + typ: rule ? rule.verkaeufer_provision_typ : null, + regel_betrag: rule ? (rule.verkaeufer_provision_betrag != null ? num(rule.verkaeufer_provision_betrag) : null) : null, + beschreibung: rule ? rule.verkaeufer_provision_beschreibung : null, + prozentsatz: num(mitarbeiterProzentsatz), + }, + tarifoptionen: { + betrag: round2(tarifoptionenSum), + namen: tarifoptionenNames, + }, + hardware_gewinn: round2(hardwareGewinn), }; } -/** - * Main function to calculate all commissions for a schaltung - * @param {Object} schaltungData - Complete schaltung data with tariff and provider info - * @param {Array} tarifoptionen - Selected tariff options - * @returns {Object} Complete commission calculation result - */ -function calculateProvision(schaltungData, tarifoptionen) { - const shopResult = calculateShopProvision(schaltungData, tarifoptionen); - const mitarbeiterResult = calculateMitarbeiterProvision(schaltungData, shopResult.berechnete_provision); - - return { - schaltung: { - id: schaltungData.id, - schaltungs_id: schaltungData.schaltungs_id, - tarif: schaltungData.tarif_name, - anbieter: schaltungData.anbieter_name - }, - shop_provision: { - typ: shopResult.typ, - betrag: shopResult.betrag, - beschreibung: shopResult.beschreibung, - berechnete_provision: shopResult.berechnete_provision, - quelle: shopResult.quelle - }, - mitarbeiter_provision: { - typ: mitarbeiterResult.typ, - betrag: mitarbeiterResult.betrag, - beschreibung: mitarbeiterResult.beschreibung, - berechnete_provision: mitarbeiterResult.berechnete_provision, - quelle: mitarbeiterResult.quelle, - prozentsatz: schaltungData.mitarbeiter_prozentsatz || 0 - }, - tarifoptionen: tarifoptionen || [], - tarifoptionen_provision: shopResult.tarifoptionen_provision - }; +function round2(n) { + return Math.round(n * 100) / 100; } -module.exports = { - calculateProvision, - calculateShopProvision, - calculateMitarbeiterProvision -}; +module.exports = { calculate, num, round2 }; diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..c3ed7a3 --- /dev/null +++ b/public/login.html @@ -0,0 +1,105 @@ + + + + + + Anmeldung - Provisions-Tracker + + + +
+

Anmeldung

+

Provisions-Tracker

+ +
+
+ + + + + + + +
+
+ + + diff --git a/public/login.js b/public/login.js new file mode 100644 index 0000000..277c4b0 --- /dev/null +++ b/public/login.js @@ -0,0 +1,67 @@ +// Login page logic. Detects whether initial setup is required and adjusts the form. + +(async function init() { + const errorBox = document.getElementById('errorBox'); + const setupNote = document.getElementById('setupNote'); + const title = document.getElementById('title'); + const submitBtn = document.getElementById('submitBtn'); + const form = document.getElementById('loginForm'); + const passwordInput = document.getElementById('password'); + + let setupMode = false; + + function showError(msg) { + errorBox.textContent = msg; + errorBox.classList.add('visible'); + } + function clearError() { + errorBox.textContent = ''; + errorBox.classList.remove('visible'); + } + + try { + const r = await fetch('/api/auth/setup-required'); + if (r.ok) { + const data = await r.json(); + setupMode = !!data.setupRequired; + } + } catch (e) { + // Fall through: stay in login mode + } + + if (setupMode) { + title.textContent = 'Ersteinrichtung'; + setupNote.style.display = 'block'; + submitBtn.textContent = 'Administrator anlegen'; + passwordInput.setAttribute('autocomplete', 'new-password'); + passwordInput.setAttribute('minlength', '8'); + } + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + clearError(); + submitBtn.disabled = true; + const username = document.getElementById('username').value.trim(); + const password = passwordInput.value; + + try { + const url = setupMode ? '/api/auth/setup' : '/api/auth/login'; + const r = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ username, password }), + }); + const data = await r.json().catch(() => ({})); + if (!r.ok) { + showError(data.error || 'Anmeldung fehlgeschlagen'); + submitBtn.disabled = false; + return; + } + window.location.href = '/'; + } catch (err) { + showError('Netzwerkfehler: ' + err.message); + submitBtn.disabled = false; + } + }); +})(); diff --git a/public/script.js b/public/script.js index a047f05..77e226b 100644 --- a/public/script.js +++ b/public/script.js @@ -1,24 +1,78 @@ // Global variables let currentSchaltungId = null; let uploadedFiles = []; +let currentUser = null; + +// Escape user-controlled values before embedding into innerHTML. Replaces all five +// XSS-relevant characters; safe inside both text content and double-quoted attributes. +function escapeHtml(value) { + if (value === null || value === undefined) return ''; + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} // Initialize the application with background loading -document.addEventListener('DOMContentLoaded', function() { +document.addEventListener('DOMContentLoaded', async function() { + // Verify session before loading anything else; redirect to /login on 401. + try { + const meRes = await fetch('/api/auth/me', { credentials: 'same-origin' }); + if (meRes.status === 401) { + window.location.href = '/login'; + return; + } + currentUser = await meRes.json(); + renderUserBadge(); + } catch (e) { + window.location.href = '/login'; + return; + } + loadAnbieter(); loadSchaltungen(); setupFileUpload(); - + // Set today's date as default const today = new Date().toISOString().split('T')[0]; document.getElementById('eingabeDatum').value = today; - + // Setup form submission document.getElementById('schaltungForm').addEventListener('submit', handleSchaltungSubmit); - + // Load all data in background for faster tab switching preloadAllData(); }); +function renderUserBadge() { + if (!currentUser) return; + let badge = document.getElementById('userBadge'); + if (!badge) { + badge = document.createElement('div'); + badge.id = 'userBadge'; + badge.style.cssText = 'position:fixed;top:12px;right:16px;z-index:9000;background:rgba(255,255,255,0.95);padding:8px 14px;border-radius:6px;font-size:13px;box-shadow:0 2px 6px rgba(0,0,0,0.15);font-family:Segoe UI,Tahoma,sans-serif;'; + document.body.appendChild(badge); + } + const name = currentUser.displayName || currentUser.username; + badge.innerHTML = ''; + const span = document.createElement('span'); + span.textContent = name + ' · '; + const link = document.createElement('a'); + link.href = '#'; + link.textContent = 'Abmelden'; + link.style.color = '#c0392b'; + link.style.textDecoration = 'none'; + link.addEventListener('click', async (e) => { + e.preventDefault(); + try { await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' }); } catch (_) {} + window.location.href = '/login'; + }); + badge.appendChild(span); + badge.appendChild(link); +} + // Preload all data in background async function preloadAllData() { console.log('Preloading all data in background...'); @@ -93,23 +147,29 @@ function showTab(tabName) { // API Helper functions async function apiRequest(url, options = {}) { try { - const response = await fetch(url, options); - - // Check if response has content + const opts = Object.assign({ credentials: 'same-origin' }, options); + const response = await fetch(url, opts); + + // Session expired or unauthenticated -> back to login + if (response.status === 401) { + window.location.href = '/login'; + throw new Error('Nicht angemeldet'); + } + const contentType = response.headers.get('content-type'); let data = null; - + if (contentType && contentType.includes('application/json')) { const text = await response.text(); if (text.trim()) { data = JSON.parse(text); } } - + if (!response.ok) { throw new Error(data?.error || `HTTP ${response.status}: ${response.statusText}`); } - + return data; } catch (error) { showMessage('Fehler: ' + error.message, 'error'); @@ -215,8 +275,8 @@ async function loadAnbieter() { tarifAnbieterSelect.innerHTML = ''; anbieter.forEach(a => { - anbieterSelect.innerHTML += ``; - tarifAnbieterSelect.innerHTML += ``; + anbieterSelect.innerHTML += ``; + tarifAnbieterSelect.innerHTML += ``; }); } catch (error) { @@ -242,7 +302,7 @@ async function loadTarife() { tarifSelect.innerHTML = ''; tarife.forEach(t => { - tarifSelect.innerHTML += ``; + tarifSelect.innerHTML += ``; }); } catch (error) { @@ -295,9 +355,9 @@ function displayTarifoptionen(tarifoptionen) { `; @@ -429,12 +489,17 @@ function displayUploadedFile(file) { const filesDiv = document.getElementById('uploadedFiles'); const fileDiv = document.createElement('div'); fileDiv.className = 'document-item'; - - fileDiv.innerHTML = ` - ${file.name} (${formatFileSize(file.size)}) - - `; - + + const span = document.createElement('span'); + span.textContent = `${file.name} (${formatFileSize(file.size)})`; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn-danger'; + btn.textContent = 'Entfernen'; + btn.addEventListener('click', () => removeFile(file.name)); + fileDiv.appendChild(span); + fileDiv.appendChild(btn); + filesDiv.appendChild(fileDiv); } @@ -497,21 +562,31 @@ async function loadSchaltungen() { schaltungen.forEach(s => { const row = document.createElement('tr'); row.innerHTML = ` - ${s.schaltungs_id} - ${formatDate(s.eingabe_datum)} - ${formatDate(s.schaltungs_datum)} - ${s.vorname} ${s.nachname} - ${s.firma || '-'} - ${s.rufnummer} - ${s.anbieter_name} - ${s.tarif_name} - ${s.hardware} - + ${escapeHtml(s.schaltungs_id)} + ${escapeHtml(formatDate(s.eingabe_datum))} + ${escapeHtml(formatDate(s.schaltungs_datum))} + ${escapeHtml(s.vorname)} ${escapeHtml(s.nachname)} + ${escapeHtml(s.firma || '-')} + ${escapeHtml(s.rufnummer)} + ${escapeHtml(s.anbieter_name)} + ${escapeHtml(s.tarif_name)} + ${escapeHtml(s.hardware || '')} + - - + + `; + // Wire delegated handlers so we don't need inline onclick (avoids JS-injection via names). + row.querySelectorAll('button[data-action]').forEach(btn => { + btn.addEventListener('click', () => { + const action = btn.dataset.action; + const id = parseInt(btn.dataset.id, 10); + if (action === 'documents') showDocuments(id); + else if (action === 'edit') editSchaltung(id); + else if (action === 'delete') deleteSchaltung(id, btn.dataset.schaltungsId, btn.dataset.name); + }); + }); tbody.appendChild(row); }); @@ -565,31 +640,38 @@ async function showDocuments(schaltungId) { // Format upload date const uploadDate = doc.uploaded_at ? formatDate(doc.uploaded_at) : 'Unbekannt'; - // Add view button for PDFs - const viewButton = fileExt === 'pdf' - ? `` - : ''; + const showView = fileExt === 'pdf'; + const safeName = escapeHtml(doc.original_filename); docItem.innerHTML = `
${icon}
-
${doc.original_filename}
-
Größe: ${fileSize} • Hochgeladen: ${uploadDate}
+
${safeName}
+
Größe: ${escapeHtml(fileSize)} • Hochgeladen: ${escapeHtml(uploadDate)}
- ${viewButton} - ` : ''} + -
`; + docItem.querySelectorAll('button[data-doc-action]').forEach(btn => { + btn.addEventListener('click', () => { + const action = btn.dataset.docAction; + const id = parseInt(btn.dataset.id, 10); + if (action === 'view') viewPdfDocument(id, btn.dataset.name); + else if (action === 'download') downloadDocument(id); + else if (action === 'delete') deleteDocument(id, btn.dataset.name, parseInt(btn.dataset.schaltungId, 10)); + }); + }); modalBody.appendChild(docItem); }); @@ -760,20 +842,21 @@ async function loadAnbieterAdmin() { } anbieter.forEach(a => { - // Format the date nicely const createdAt = new Date(a.created_at).toLocaleDateString('de-DE'); - - const row = ` - - ${a.id} - ${a.name} - ${createdAt} - - - - + const tr = document.createElement('tr'); + tr.innerHTML = ` + ${a.id} + ${escapeHtml(a.name)} + ${escapeHtml(createdAt)} + + + `; - tbody.innerHTML += row; + tr.querySelector('button[data-anbieter-action="delete"]').addEventListener('click', (e) => { + const btn = e.currentTarget; + deleteAnbieter(parseInt(btn.dataset.id, 10), btn.dataset.name); + }); + tbody.appendChild(tr); }); } catch (error) { @@ -807,21 +890,28 @@ async function loadTarifeAdmin() { tarife.forEach(t => { const provision = `${(t.provision_euro || 0).toFixed(2).replace('.', ',')}€`; - - const row = ` - - ${t.id} - ${t.anbieter_name || 'Unbekannt'} - ${t.name} - ${provision} - ${t.beschreibung || '-'} - - - - - + const tr = document.createElement('tr'); + tr.id = `tarif-item-${t.id}`; + tr.innerHTML = ` + ${t.id} + ${escapeHtml(t.anbieter_name || 'Unbekannt')} + ${escapeHtml(t.name)} + ${escapeHtml(provision)} + ${escapeHtml(t.beschreibung || '-')} + + + + `; - tbody.innerHTML += row; + tr.querySelectorAll('button[data-tarif-action]').forEach(btn => { + btn.addEventListener('click', () => { + const action = btn.dataset.tarifAction; + const id = parseInt(btn.dataset.id, 10); + if (action === 'edit') editTariff(id); + else if (action === 'delete') deleteTariff(id, btn.dataset.name); + }); + }); + tbody.appendChild(tr); }); } catch (error) { @@ -870,7 +960,7 @@ async function loadAnbieterForTarife() { select.innerHTML = ''; anbieter.forEach(a => { - select.innerHTML += ``; + select.innerHTML += ``; }); } catch (error) { console.error('Fehler beim Laden der Anbieter für Tarife:', error); @@ -885,7 +975,7 @@ async function loadMitarbeiterAnbieter() { select.innerHTML = ''; anbieter.forEach(a => { - select.innerHTML += ``; + select.innerHTML += ``; }); } catch (error) { console.error('Fehler beim Laden der Anbieter für Mitarbeiter:', error); @@ -900,7 +990,7 @@ async function loadTarifoptionAnbieter() { select.innerHTML = ''; anbieter.forEach(a => { - select.innerHTML += ``; + select.innerHTML += ``; }); } catch (error) { console.error('Fehler beim Laden der Anbieter für Tarifoptionen:', error); @@ -1001,7 +1091,7 @@ async function loadTarifeForProvision() { const tarife = await apiRequest(`/api/tarife/${a.id}`); if (tarife.length > 0) { tarife.forEach(t => { - provisionTarifSelect.innerHTML += ``; + provisionTarifSelect.innerHTML += ``; }); } } @@ -1028,25 +1118,31 @@ async function loadProvisionsregelnAdmin() { if (regeln.length > 0) { const providerDiv = document.createElement('div'); - providerDiv.innerHTML = `

${a.name} - ${t.name}

`; + const h4 = document.createElement('h4'); + h4.textContent = `${a.name} - ${t.name}`; + providerDiv.appendChild(h4); provisionsregelnList.appendChild(providerDiv); - + regeln.forEach(r => { const item = document.createElement('div'); item.className = 'list-item'; item.style.marginLeft = '20px'; - + const typText = r.provision_typ === 'fix' ? `${r.betrag} €` : `${r.betrag} %`; const statusText = r.aktiv ? 'Aktiv' : 'Inaktiv'; - + item.innerHTML = ` - ${typText} - ${r.beschreibung || 'Keine Beschreibung'} (${statusText}) + ${escapeHtml(typText)} - ${escapeHtml(r.beschreibung || 'Keine Beschreibung')} (${escapeHtml(statusText)})
-
`; + item.querySelector('button[data-regel-action]').addEventListener('click', (e) => { + const btn = e.currentTarget; + toggleProvisionsregel(parseInt(btn.dataset.id, 10), btn.dataset.aktiv === 'true'); + }); provisionsregelnList.appendChild(item); }); } @@ -1144,7 +1240,7 @@ async function loadGlobalProvisionAnbieter() { globalProvisionAnbieterSelect.innerHTML = ''; anbieter.forEach(a => { - globalProvisionAnbieterSelect.innerHTML += ``; + globalProvisionAnbieterSelect.innerHTML += ``; }); } catch (error) { @@ -1166,36 +1262,41 @@ async function loadGlobalProvisionsregelnAdmin() { if (regeln.length > 0) { const providerDiv = document.createElement('div'); - providerDiv.innerHTML = `

Globale Regeln für ${a.name}

`; + const h4 = document.createElement('h4'); + h4.textContent = `Globale Regeln für ${a.name}`; + providerDiv.appendChild(h4); globalProvisionsregelnList.appendChild(providerDiv); - + regeln.forEach(r => { const item = document.createElement('div'); item.className = 'list-item'; item.style.marginLeft = '20px'; - - // Show employee commission instead of shop commission + let displayText = 'Keine Mitarbeiter-Provision definiert'; if (r.verkaeufer_provision_typ && r.verkaeufer_provision_betrag) { - const mitarbeiterText = r.verkaeufer_provision_typ === 'fix' - ? `${r.verkaeufer_provision_betrag} €` + const mitarbeiterText = r.verkaeufer_provision_typ === 'fix' + ? `${r.verkaeufer_provision_betrag} €` : `${r.verkaeufer_provision_betrag} %`; displayText = `Mitarbeiter: ${mitarbeiterText}`; if (r.verkaeufer_provision_beschreibung) { displayText += ` - ${r.verkaeufer_provision_beschreibung}`; } } - + const statusText = r.aktiv ? 'Aktiv' : 'Inaktiv'; - + item.innerHTML = ` - ${displayText} (${statusText}) + ${escapeHtml(displayText)} (${escapeHtml(statusText)})
-
`; + item.querySelector('button[data-global-action]').addEventListener('click', (e) => { + const btn = e.currentTarget; + toggleGlobalProvisionsregel(parseInt(btn.dataset.id, 10), btn.dataset.aktiv === 'true'); + }); globalProvisionsregelnList.appendChild(item); }); } @@ -1417,7 +1518,7 @@ async function editSchaltung(id) { editAnbieterSelect.innerHTML = ''; anbieter.forEach(a => { - editAnbieterSelect.innerHTML += ``; + editAnbieterSelect.innerHTML += ``; }); // Fill modal with schaltung data @@ -1581,7 +1682,7 @@ async function loadTarifeForEdit() { tarifSelect.innerHTML = ''; tarife.forEach(t => { - tarifSelect.innerHTML += ``; + tarifSelect.innerHTML += ``; }); // Clear tarifoptionen when anbieter changes @@ -1716,17 +1817,17 @@ async function loadGeloeschteSchaltungen() { geloeschte.forEach(s => { const row = document.createElement('tr'); row.innerHTML = ` - ${s.schaltungs_id} - ${formatDate(s.eingabe_datum)} - ${formatDate(s.schaltungs_datum)} - ${s.vorname} ${s.nachname} - ${s.firma || '-'} - ${s.rufnummer} - ${s.anbieter_name} - ${s.tarif_name} - ${s.hardware} - ${s.loeschgrund} - ${formatDateTime(s.geloescht_am)} + ${escapeHtml(s.schaltungs_id)} + ${escapeHtml(formatDate(s.eingabe_datum))} + ${escapeHtml(formatDate(s.schaltungs_datum))} + ${escapeHtml(s.vorname)} ${escapeHtml(s.nachname)} + ${escapeHtml(s.firma || '-')} + ${escapeHtml(s.rufnummer)} + ${escapeHtml(s.anbieter_name)} + ${escapeHtml(s.tarif_name)} + ${escapeHtml(s.hardware || '')} + ${escapeHtml(s.loeschgrund)} + ${escapeHtml(formatDateTime(s.geloescht_am))} `; tbody.appendChild(row); }); @@ -1775,7 +1876,7 @@ async function updateFilterDropdowns() { if (anbieterOverview) { anbieterOverview.innerHTML = ''; anbieter.forEach(a => { - anbieterOverview.innerHTML += ``; + anbieterOverview.innerHTML += ``; }); } @@ -1784,7 +1885,7 @@ async function updateFilterDropdowns() { if (anbieterProvisionen) { anbieterProvisionen.innerHTML = ''; anbieter.forEach(a => { - anbieterProvisionen.innerHTML += ``; + anbieterProvisionen.innerHTML += ``; }); } @@ -1808,10 +1909,10 @@ async function updateFilterDropdowns() { const optionValue = t.name; if (tarifOverview) { - tarifOverview.innerHTML += ``; + tarifOverview.innerHTML += ``; } if (tarifProvisionen) { - tarifProvisionen.innerHTML += ``; + tarifProvisionen.innerHTML += ``; } }); } catch (error) { @@ -1961,23 +2062,32 @@ function updateSchaltungenTable(schaltungen) { schaltungen.forEach(s => { const row = document.createElement('tr'); row.innerHTML = ` - ${s.schaltungs_id} - ${formatDate(s.eingabe_datum)} - ${formatDate(s.schaltungs_datum)} - ${s.vorname} ${s.nachname} - ${s.firma || '-'} - ${s.rufnummer} - ${s.anbieter_name} - ${s.tarif_name} - ${s.hardware} + ${escapeHtml(s.schaltungs_id)} + ${escapeHtml(formatDate(s.eingabe_datum))} + ${escapeHtml(formatDate(s.schaltungs_datum))} + ${escapeHtml(s.vorname)} ${escapeHtml(s.nachname)} + ${escapeHtml(s.firma || '-')} + ${escapeHtml(s.rufnummer)} + ${escapeHtml(s.anbieter_name)} + ${escapeHtml(s.tarif_name)} + ${escapeHtml(s.hardware || '')} - + - - + + `; + row.querySelectorAll('button[data-filt-action]').forEach(btn => { + btn.addEventListener('click', () => { + const action = btn.dataset.filtAction; + const id = parseInt(btn.dataset.id, 10); + if (action === 'documents') showDocuments(id); + else if (action === 'edit') editSchaltung(id); + else if (action === 'delete') deleteSchaltung(id, btn.dataset.schaltungsId, btn.dataset.name); + }); + }); tbody.appendChild(row); }); } @@ -2010,15 +2120,15 @@ function updateProvisionenTable(schaltungen, totalShopProvision, totalMitarbeite schaltungen.forEach(s => { const row = document.createElement('tr'); - + row.innerHTML = ` - ${s.schaltungs_id} - ${formatDate(s.schaltungs_datum)} - ${s.vorname} ${s.nachname} - ${s.anbieter_name} - ${s.tarif_name} - ${s.berechnete_shop_provision || '0.00'} € - ${s.berechnete_mitarbeiter_provision || '0.00'} € + ${escapeHtml(s.schaltungs_id)} + ${escapeHtml(formatDate(s.schaltungs_datum))} + ${escapeHtml(s.vorname)} ${escapeHtml(s.nachname)} + ${escapeHtml(s.anbieter_name)} + ${escapeHtml(s.tarif_name)} + ${escapeHtml(s.berechnete_shop_provision || '0.00')} € + ${escapeHtml(s.berechnete_mitarbeiter_provision || '0.00')} € `; tbody.appendChild(row); }); @@ -2106,7 +2216,7 @@ async function loadAnbieterForTarifoptionen() { const select = document.getElementById('tarifoptionAnbieter'); select.innerHTML = ''; anbieter.forEach(a => { - select.innerHTML += ``; + select.innerHTML += ``; }); } catch (error) { console.error('Fehler beim Laden der Anbieter:', error); @@ -2136,7 +2246,7 @@ async function loadTarifeForOptions() {
`; @@ -2226,15 +2336,14 @@ async function loadTarifeForEditOptions() { let checkboxHtml = '
'; tarife.forEach((tarif, index) => { - const provisionText = tarif.provision_euro ? ` (${tarif.provision_euro}€)` : ''; checkboxHtml += ` -
+
@@ -2336,7 +2445,7 @@ async function loadAnbieterForAddModal() { const anbieterSelect = document.getElementById('addTarifoptionAnbieter'); anbieterSelect.innerHTML = ''; anbieter.forEach(a => { - anbieterSelect.innerHTML += ``; + anbieterSelect.innerHTML += ``; }); } catch (error) { console.error('Fehler beim Laden der Anbieter:', error); @@ -2388,13 +2497,13 @@ async function loadTarifeForAddOptions() { let checkboxHtml = '
'; 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 = `
-

${a.name}

+

${escapeHtml(a.name)}

`; 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 = `
-

${tarif.name} (${tarif.anbieter_name})

- Basis-Provision: ${tarif.provision_euro}€ +

${escapeHtml(tarif.name)} (${escapeHtml(tarif.anbieter_name)})

+ Basis-Provision: ${escapeHtml(String(tarif.provision_euro))}€
- -
@@ -3105,6 +3229,9 @@ async function loadTarifProvisionsAdmin() {
`; + 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); +});