Major refactor: auth, bug fixes, security hardening, modular routes

Bug fixes
- Sync database.js schema with actual production DB (tarifoptionen
  shape was wrong; new installs would have broken).
- Fix SQL syntax error `SELECT to.*` (reserved keyword) in single-
  schaltung provision endpoint, plus the dead fallback hierarchy
  that selected columns that were never in the query.
- Fix PUT /api/tarifoptionen/:id writing to non-existent column
  provision_betrag (should be provision_euro).
- Fix race condition in PUT /api/schaltungen/:id where forEach with
  async db.run inside a transaction could send duplicate responses.
- Delete dokumente rows explicitly before deleting schaltung
  (existing DBs lack ON DELETE CASCADE on dokumente FK).
- Replace hardcoded basisBetrag=100 for percent rules; basis is now
  configurable per rule via the new basis_euro column.

Security
- Multi-user authentication: users table, bcryptjs, express-session
  with SQLite store, login/logout/me/setup/change-password endpoints.
- Auth gate on all /api/* except /api/auth/*.
- CLI to create users: npm run create-user -- <user> <pass> [--admin].
- helmet for security headers.
- CORS restricted to ALLOWED_ORIGINS env (default same-origin).
- Rate limits: 300/min global, 20/15min on login.
- Path-traversal validation on document download/delete.
- XSS hardening in frontend: escapeHtml helper applied to all
  DB-derived innerHTML interpolations; inline onclick handlers that
  embedded user-controlled strings replaced with data-* attrs +
  addEventListener.

Architecture
- Provision calculation centralised in provisionService.js as the
  single source of truth (was duplicated and inconsistent).
- server.js cut from 2138 to ~140 lines; routes split into per-
  resource modules under routes/.
- Promise wrapper db.js for sqlite3 callbacks; async/await in routes.
- middleware/ for auth and async error handling.

Tests
- 14 unit tests for provisionService covering fallback hierarchy,
  percent calculation, tarifoptionen, hardware logic, employee
  commission. All green.

Config
- dotenv loads .env.local at startup (gitignored).
- .gitignore extended for sessions.db and .env*.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kerimatilganandClaude Opus 4.7 committed 2026-05-14 21:43:19 +02:00
1 parent 55ec916f35
commit 4303b00b9b
23 files changed
+2427 -2575

No files matched your search

+6
View File
@@ -13,6 +13,12 @@ uploads/
# Local Claude settings # Local Claude settings
.claude/ .claude/
# Local environment / secrets
.env
.env.local
.env.*.local
sessions.db
# OS / Editor # OS / Editor
.DS_Store .DS_Store
Thumbs.db Thumbs.db
+94 -138
View File
@@ -3,10 +3,8 @@ const path = require('path');
const dbPath = path.join(__dirname, 'provision.db'); const dbPath = path.join(__dirname, 'provision.db');
// Singleton database instance
let dbInstance = null; let dbInstance = null;
// Get or create database instance (Singleton pattern)
function getDatabase() { function getDatabase() {
if (!dbInstance) { if (!dbInstance) {
dbInstance = new sqlite3.Database(dbPath, (err) => { dbInstance = new sqlite3.Database(dbPath, (err) => {
@@ -18,13 +16,11 @@ function getDatabase() {
console.log('Connected to SQLite database'); console.log('Connected to SQLite database');
}); });
// Set busy timeout to handle concurrent access
dbInstance.configure('busyTimeout', 3000); dbInstance.configure('busyTimeout', 3000);
} }
return dbInstance; return dbInstance;
} }
// Enable foreign keys for a database connection
function enableForeignKeys(db) { function enableForeignKeys(db) {
db.run('PRAGMA foreign_keys = ON', (err) => { db.run('PRAGMA foreign_keys = ON', (err) => {
if (err) { if (err) {
@@ -33,7 +29,6 @@ function enableForeignKeys(db) {
}); });
} }
// Close database connection (for graceful shutdown)
function closeDatabase() { function closeDatabase() {
if (dbInstance) { if (dbInstance) {
dbInstance.close((err) => { 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() { function initDatabase() {
const db = getDatabase(); const db = getDatabase();
db.serialize(() => { db.serialize(() => {
// Anbieter (Providers) table // Anbieter (Providers)
db.run(`CREATE TABLE IF NOT EXISTS anbieter ( db.run(`CREATE TABLE IF NOT EXISTS anbieter (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP 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 ( db.run(`CREATE TABLE IF NOT EXISTS mitarbeiter_prozentsaetze (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
anbieter_id INTEGER NOT NULL, anbieter_id INTEGER NOT NULL,
@@ -69,8 +73,8 @@ function initDatabase() {
FOREIGN KEY (anbieter_id) REFERENCES anbieter (id), FOREIGN KEY (anbieter_id) REFERENCES anbieter (id),
UNIQUE(anbieter_id, gueltig_ab) UNIQUE(anbieter_id, gueltig_ab)
)`); )`);
// Tarife (Tariffs) table - simplified with fixed euro amounts only // Tarife
db.run(`CREATE TABLE IF NOT EXISTS tarife ( db.run(`CREATE TABLE IF NOT EXISTS tarife (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -82,18 +86,15 @@ function initDatabase() {
FOREIGN KEY (anbieter_id) REFERENCES anbieter (id), FOREIGN KEY (anbieter_id) REFERENCES anbieter (id),
UNIQUE(name, anbieter_id) UNIQUE(name, anbieter_id)
)`); )`);
addColumnIfMissing(db, 'tarife', 'mitarbeiter_provision DECIMAL(10,2) DEFAULT 0');
// 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) => { // Tariff-specific commission rules. basis_euro: base amount for percent calculation.
// Ignore error if column already exists
});
// Provision rules table (tariff-specific)
db.run(`CREATE TABLE IF NOT EXISTS provisionsregeln ( db.run(`CREATE TABLE IF NOT EXISTS provisionsregeln (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
tarif_id INTEGER NOT NULL, tarif_id INTEGER NOT NULL,
provision_typ TEXT NOT NULL CHECK(provision_typ IN ('fix', 'prozent')), provision_typ TEXT NOT NULL CHECK(provision_typ IN ('fix', 'prozent')),
betrag DECIMAL(10,2) NOT NULL, betrag DECIMAL(10,2) NOT NULL,
basis_euro DECIMAL(10,2),
beschreibung TEXT, beschreibung TEXT,
verkaeufer_provision_typ TEXT CHECK(verkaeufer_provision_typ IN ('fix', 'prozent')), verkaeufer_provision_typ TEXT CHECK(verkaeufer_provision_typ IN ('fix', 'prozent')),
verkaeufer_provision_betrag DECIMAL(10,2), verkaeufer_provision_betrag DECIMAL(10,2),
@@ -103,13 +104,15 @@ function initDatabase() {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tarif_id) REFERENCES tarife (id) FOREIGN KEY (tarif_id) REFERENCES tarife (id)
)`); )`);
addColumnIfMissing(db, 'provisionsregeln', 'basis_euro DECIMAL(10,2)');
// Global provider commission rules table
// Global provider commission rules
db.run(`CREATE TABLE IF NOT EXISTS anbieter_provisionsregeln ( db.run(`CREATE TABLE IF NOT EXISTS anbieter_provisionsregeln (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
anbieter_id INTEGER NOT NULL, anbieter_id INTEGER NOT NULL,
provision_typ TEXT NOT NULL CHECK(provision_typ IN ('fix', 'prozent')), provision_typ TEXT NOT NULL CHECK(provision_typ IN ('fix', 'prozent')),
betrag DECIMAL(10,2) NOT NULL, betrag DECIMAL(10,2) NOT NULL,
basis_euro DECIMAL(10,2),
beschreibung TEXT, beschreibung TEXT,
verkaeufer_provision_typ TEXT CHECK(verkaeufer_provision_typ IN ('fix', 'prozent')), verkaeufer_provision_typ TEXT CHECK(verkaeufer_provision_typ IN ('fix', 'prozent')),
verkaeufer_provision_betrag DECIMAL(10,2), verkaeufer_provision_betrag DECIMAL(10,2),
@@ -119,8 +122,9 @@ function initDatabase() {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (anbieter_id) REFERENCES anbieter (id) FOREIGN KEY (anbieter_id) REFERENCES anbieter (id)
)`); )`);
addColumnIfMissing(db, 'anbieter_provisionsregeln', 'basis_euro DECIMAL(10,2)');
// Schaltungen (Connections/Commissions) table
// Schaltungen
db.run(`CREATE TABLE IF NOT EXISTS schaltungen ( db.run(`CREATE TABLE IF NOT EXISTS schaltungen (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
schaltungs_id TEXT NOT NULL UNIQUE, schaltungs_id TEXT NOT NULL UNIQUE,
@@ -139,16 +143,11 @@ function initDatabase() {
FOREIGN KEY (anbieter_id) REFERENCES anbieter (id), FOREIGN KEY (anbieter_id) REFERENCES anbieter (id),
FOREIGN KEY (tarif_id) REFERENCES tarife (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) // Dokumente (note: ON DELETE CASCADE only applied to new DBs - existing DBs
db.run(`ALTER TABLE schaltungen ADD COLUMN hardware_ek DECIMAL(10,2)`, (err) => { // handled via explicit DELETE in server.js)
// 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
db.run(`CREATE TABLE IF NOT EXISTS dokumente ( db.run(`CREATE TABLE IF NOT EXISTS dokumente (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
schaltung_id INTEGER NOT NULL, schaltung_id INTEGER NOT NULL,
@@ -160,8 +159,8 @@ function initDatabase() {
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP, uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (schaltung_id) REFERENCES schaltungen (id) ON DELETE CASCADE 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 ( db.run(`CREATE TABLE IF NOT EXISTS geloeschte_schaltungen (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
original_id INTEGER NOT NULL, original_id INTEGER NOT NULL,
@@ -181,39 +180,25 @@ function initDatabase() {
geloescht_am DATETIME DEFAULT CURRENT_TIMESTAMP, geloescht_am DATETIME DEFAULT CURRENT_TIMESTAMP,
geloescht_von TEXT DEFAULT 'System' geloescht_von TEXT DEFAULT 'System'
)`); )`);
// Insert some default providers and tariffs for testing // Tarifoptionen - schema matches production: anbieter_id required, tarif_id optional
db.run(`INSERT OR IGNORE INTO anbieter (name) VALUES // (NULL = anbieter-wide), unique per (anbieter_id, tarif_id, name).
('Telekom'), db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen (
('Vodafone'), id INTEGER PRIMARY KEY AUTOINCREMENT,
('O2'), anbieter_id INTEGER NOT NULL,
('1&1')`); tarif_id INTEGER,
name TEXT NOT NULL,
db.run(`INSERT OR IGNORE INTO tarife (name, anbieter_id, provision_euro, beschreibung) VALUES code TEXT,
('MagentaMobil S', 1, 150.00, 'Telekom Basis-Tarif'), provision_euro DECIMAL(10,2) DEFAULT 0,
('MagentaMobil M', 1, 200.00, 'Telekom Standard-Tarif'), beschreibung TEXT,
('MagentaMobil L', 1, 250.00, 'Telekom Premium-Tarif'), aktiv BOOLEAN DEFAULT 1,
('VF green LTE 10GB mit Smartphone 5', 2, 190.00, 'Vodafone Standard-Paket'), created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
('Red S', 2, 120.00, 'Vodafone Einsteiger-Tarif'), FOREIGN KEY (anbieter_id) REFERENCES anbieter (id),
('Red M', 2, 160.00, 'Vodafone Standard-Tarif'), FOREIGN KEY (tarif_id) REFERENCES tarife (id),
('Red L', 2, 200.00, 'Vodafone Premium-Tarif'), UNIQUE(anbieter_id, tarif_id, name)
('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')`); // M:N variant kept for the /multi endpoints
// 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
db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen_new ( db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen_new (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
@@ -224,7 +209,6 @@ function initDatabase() {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`); )`);
// Many-to-many relationship between tarifoptionen and tarife
db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen_tarife ( db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen_tarife (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
tarifoption_id INTEGER NOT NULL, tarifoption_id INTEGER NOT NULL,
@@ -235,21 +219,7 @@ function initDatabase() {
UNIQUE(tarifoption_id, tarif_id) UNIQUE(tarifoption_id, tarif_id)
)`); )`);
// Keep old table for migration compatibility // Link table: which tarifoptionen are assigned to which schaltung
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)
db.run(`CREATE TABLE IF NOT EXISTS schaltungen_tarifoptionen ( db.run(`CREATE TABLE IF NOT EXISTS schaltungen_tarifoptionen (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
schaltung_id INTEGER NOT NULL, schaltung_id INTEGER NOT NULL,
@@ -259,75 +229,61 @@ function initDatabase() {
FOREIGN KEY (tarifoption_id) REFERENCES tarifoptionen (id), FOREIGN KEY (tarifoption_id) REFERENCES tarifoptionen (id),
UNIQUE(schaltung_id, tarifoption_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 // Users for authentication
function migrateToNewTarifoptionen() { db.run(`CREATE TABLE IF NOT EXISTS users (
db.serialize(() => { id INTEGER PRIMARY KEY AUTOINCREMENT,
// Check if migration is needed username TEXT NOT NULL UNIQUE,
db.get(`SELECT COUNT(*) as count FROM tarifoptionen_new`, (err, row) => { password_hash TEXT NOT NULL,
if (err || row.count > 0) return; // Already migrated or error 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.run(`INSERT OR IGNORE INTO tarife (name, anbieter_id, provision_euro, beschreibung) VALUES
db.all(`SELECT DISTINCT name, code, provision_euro, beschreibung, aktiv, created_at FROM tarifoptionen`, (err, oldOptions) => { ('MagentaMobil S', 1, 150.00, 'Telekom Basis-Tarif'),
if (err || !oldOptions.length) return; ('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 => { db.run(`INSERT OR IGNORE INTO mitarbeiter_prozentsaetze (anbieter_id, prozentsatz, beschreibung) VALUES
// Insert unique tarifoption into new table (1, 8.00, 'Telekom Mitarbeiter-Provision'),
db.run(`INSERT OR IGNORE INTO tarifoptionen_new (name, code, provision_euro, beschreibung, aktiv, created_at) (2, 10.00, 'Vodafone/Freenet Mitarbeiter-Provision'),
VALUES (?, ?, ?, ?, ?, ?)`, (3, 7.50, 'O2 Mitarbeiter-Provision'),
[option.name, option.code, option.provision_euro, option.beschreibung, option.aktiv, option.created_at], (4, 9.00, '1&1 Mitarbeiter-Provision')`);
function(err) {
if (err) return;
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 // Seed tarifoptionen (correct schema: anbieter_id required)
db.all(`SELECT tarif_id FROM tarifoptionen WHERE name = ? AND code = ?`, db.run(`INSERT OR IGNORE INTO tarifoptionen (anbieter_id, tarif_id, name, code, provision_euro, beschreibung) VALUES
[option.name, option.code], (1, 1, 'Magenta Premium Option', 'MAG-PREM', 50.00, 'Premium-Zusatz für Telekom'),
(err, tarifs) => { (1, 1, 'Aktionscode Neukunde', 'NEU2024', 30.00, 'Neukunden-Bonus'),
if (err) return; (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 db.run(`INSERT OR IGNORE INTO provisionsregeln (tarif_id, provision_typ, betrag, beschreibung) VALUES
(1, 'fix', 25.00, 'Feste Provision für MagentaMobil S'), (1, 'fix', 25.00, 'Feste Provision für MagentaMobil S'),
(2, 'fix', 35.00, 'Feste Provision für MagentaMobil M'), (2, 'fix', 35.00, 'Feste Provision für MagentaMobil M'),
(3, 'fix', 45.00, 'Feste Provision für MagentaMobil L'), (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')`);
}); });
// Enable foreign keys AFTER all tables and initial data are created
enableForeignKeys(db); enableForeignKeys(db);
} }
module.exports = { initDatabase, getDatabase, closeDatabase }; module.exports = { initDatabase, getDatabase, closeDatabase };
+44
View File
@@ -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 };
+5
View File
@@ -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);
};
+17
View File
@@ -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 };
+117
View File
@@ -8,8 +8,14 @@
"name": "provision-tracker", "name": "provision-tracker",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3",
"connect-sqlite3": "^0.9.13",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.4.2",
"express": "^4.18.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", "multer": "^1.4.5-lts.1",
"sqlite3": "^5.1.6" "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": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -510,6 +521,17 @@
"typedarray": "^0.0.6" "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": { "node_modules/console-control-strings": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
@@ -626,6 +648,17 @@
"node": ">=8" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -795,6 +828,55 @@
"url": "https://opencollective.com/express" "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": { "node_modules/file-uri-to-path": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "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": ">= 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": { "node_modules/http-cache-semantics": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -1738,6 +1828,14 @@
"node": ">= 0.8" "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": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -1885,6 +1983,14 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "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", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" "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": { "node_modules/undefsafe": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
+12 -4
View File
@@ -5,15 +5,23 @@
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"dev": "nodemon server.js" "dev": "nodemon server.js",
"test": "node --test tests/",
"create-user": "node scripts/create-user.js"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3",
"connect-sqlite3": "^0.9.13",
"cors": "^2.8.5",
"dotenv": "^17.4.2",
"express": "^4.18.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", "multer": "^1.4.5-lts.1",
"cors": "^2.8.5" "sqlite3": "^5.1.6"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.1" "nodemon": "^3.0.1"
} }
} }
+135 -167
View File
@@ -1,184 +1,152 @@
/** // Commission calculation service. Single source of truth used by all provision endpoints.
* Commission Calculation Service //
* // Hierarchy for shop commission (highest priority wins):
* This service handles all commission calculations for schaltungen. // 1. Tariff-specific rule (provisionsregeln, active, gueltig_ab <= schaltungs_datum)
* It implements a fallback hierarchy for determining commission rates: // 2. Global provider rule (anbieter_provisionsregeln, active, gueltig_ab <= schaltungs_datum)
* 1. Direct tariff commission (highest priority) // 3. Direct tariff amount (tarife.provision_euro)
* 2. Tariff-specific rules //
* 3. Global provider rules (fallback) // For type='prozent' the rule's basis_euro field is the base amount the percentage
* 4. Tariff options (additive) // 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.
/** function num(value) {
* Calculate shop commission based on hierarchy const n = parseFloat(value);
* @param {Object} schaltungData - Data from database including tariff and provider info return Number.isFinite(n) ? n : 0;
* @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 pickRule(context) {
* Calculate employee commission based on hierarchy // Returns the active rule that should apply, or null.
* @param {Object} schaltungData - Data from database including tariff and provider info // context: { tarifRule, anbieterRule, tarif }
* @param {number} shopProvision - Calculated shop commission (for percentage calculations) if (context.tarifRule) {
* @returns {Object} Calculation result with amount, type, description, and source return { source: 'Tarif-spezifische Regel', rule: context.tarifRule };
*/ }
function calculateMitarbeiterProvision(schaltungData, shopProvision) { if (context.anbieterRule) {
let provision_typ = null; return { source: 'Globale Anbieter-Regel', rule: context.anbieterRule };
let provision_betrag = null; }
let provision_beschreibung = null; if (context.tarif && context.tarif.provision_euro != null && num(context.tarif.provision_euro) !== 0) {
let berechnete_provision = 0; return {
let quelle = 'Keine Mitarbeiter-Provision definiert'; 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 function applyShopRule(rule) {
if (schaltungData.tarif_verkaeufer_provision_typ && schaltungData.tarif_verkaeufer_provision_betrag) { if (!rule) return 0;
provision_typ = schaltungData.tarif_verkaeufer_provision_typ; if (rule.provision_typ === 'fix') {
provision_betrag = schaltungData.tarif_verkaeufer_provision_betrag; return num(rule.betrag);
provision_beschreibung = schaltungData.tarif_verkaeufer_provision_beschreibung;
quelle = 'Direkte Tarif-Verkäufer-Provision';
} }
// Priority 2: Tariff-specific rule employee commission if (rule.provision_typ === 'prozent') {
else if (schaltungData.regel_verkaeufer_provision_typ && schaltungData.regel_verkaeufer_provision_betrag) { const basis = num(rule.basis_euro);
provision_typ = schaltungData.regel_verkaeufer_provision_typ; return (num(rule.betrag) / 100) * basis;
provision_betrag = schaltungData.regel_verkaeufer_provision_betrag;
provision_beschreibung = schaltungData.regel_verkaeufer_provision_beschreibung;
quelle = 'Tarif-spezifische Verkäufer-Regel';
} }
// Priority 3: Global provider rule employee commission return 0;
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; function applyEmployeeRule(rule, shopProvision) {
provision_beschreibung = schaltungData.global_verkaeufer_provision_beschreibung; if (!rule) return 0;
quelle = 'Globale Anbieter-Verkäufer-Regel'; 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 let employeeProvision = applyEmployeeRule(rule, shopProvision);
if (provision_typ && provision_betrag) { let employeeSource = picked ? `${picked.source} (Verkäufer)` : 'Keine Mitarbeiter-Provision definiert';
if (provision_typ === 'fix') {
berechnete_provision = parseFloat(provision_betrag); // Fallback: if no rule defines an employee commission, use the per-anbieter
} else if (provision_typ === 'prozent') { // mitarbeiter_prozentsatz against the computed shop commission.
// If percentage, calculate based on shop commission if (employeeProvision === 0 && (!rule || !rule.verkaeufer_provision_typ)) {
berechnete_provision = (parseFloat(provision_betrag) / 100) * shopProvision; const p = num(mitarbeiterProzentsatz);
if (p !== 0) {
employeeProvision = (p / 100) * shopProvision;
employeeSource = `${p}% Mitarbeiter-Anteil von ${shopProvision.toFixed(2)}€`;
} }
} }
return { return {
typ: provision_typ, shop: {
betrag: provision_betrag, betrag: round2(shopProvision),
beschreibung: provision_beschreibung, quelle: ruleSource,
berechnete_provision: berechnete_provision, typ: rule ? rule.provision_typ : null,
quelle: quelle 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),
}; };
} }
/** function round2(n) {
* Main function to calculate all commissions for a schaltung return Math.round(n * 100) / 100;
* @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
};
} }
module.exports = { module.exports = { calculate, num, round2 };
calculateProvision,
calculateShopProvision,
calculateMitarbeiterProvision
};
+105
View File
@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anmeldung - Provisions-Tracker</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #2c3e50;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.card {
background: white;
padding: 40px;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.2);
width: 100%;
max-width: 400px;
}
h1 {
color: #2c3e50;
margin-bottom: 8px;
font-size: 24px;
}
.subtitle {
color: #7f8c8d;
margin-bottom: 28px;
font-size: 14px;
}
label {
display: block;
margin-bottom: 6px;
color: #34495e;
font-weight: 500;
font-size: 14px;
}
input[type=text], input[type=password] {
width: 100%;
padding: 12px;
border: 1px solid #bdc3c7;
border-radius: 6px;
font-size: 15px;
margin-bottom: 18px;
}
input:focus { outline: none; border-color: #3498db; }
button {
width: 100%;
padding: 12px;
background: #3498db;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
}
button:hover { background: #2980b9; }
button:disabled { background: #95a5a6; cursor: not-allowed; }
.error {
background: #fee;
color: #c0392b;
padding: 10px;
border-radius: 6px;
margin-bottom: 16px;
font-size: 14px;
display: none;
}
.error.visible { display: block; }
.setup-note {
background: #fff8e1;
color: #8a6d3b;
padding: 10px;
border-radius: 6px;
margin-bottom: 16px;
font-size: 13px;
}
</style>
</head>
<body>
<div class="card">
<h1 id="title">Anmeldung</h1>
<p class="subtitle" id="subtitle">Provisions-Tracker</p>
<div id="setupNote" class="setup-note" style="display:none">
Noch kein Benutzer eingerichtet. Dieser Account wird als Administrator angelegt.
</div>
<div id="errorBox" class="error"></div>
<form id="loginForm" autocomplete="on">
<label for="username">Benutzername</label>
<input type="text" id="username" name="username" required autocomplete="username">
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
<button type="submit" id="submitBtn">Anmelden</button>
</form>
</div>
<script src="/login.js"></script>
</body>
</html>
+67
View File
@@ -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;
}
});
})();
+307 -180
View File
@@ -1,24 +1,78 @@
// Global variables // Global variables
let currentSchaltungId = null; let currentSchaltungId = null;
let uploadedFiles = []; 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Initialize the application with background loading // 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(); loadAnbieter();
loadSchaltungen(); loadSchaltungen();
setupFileUpload(); setupFileUpload();
// Set today's date as default // Set today's date as default
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
document.getElementById('eingabeDatum').value = today; document.getElementById('eingabeDatum').value = today;
// Setup form submission // Setup form submission
document.getElementById('schaltungForm').addEventListener('submit', handleSchaltungSubmit); document.getElementById('schaltungForm').addEventListener('submit', handleSchaltungSubmit);
// Load all data in background for faster tab switching // Load all data in background for faster tab switching
preloadAllData(); 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 // Preload all data in background
async function preloadAllData() { async function preloadAllData() {
console.log('Preloading all data in background...'); console.log('Preloading all data in background...');
@@ -93,23 +147,29 @@ function showTab(tabName) {
// API Helper functions // API Helper functions
async function apiRequest(url, options = {}) { async function apiRequest(url, options = {}) {
try { try {
const response = await fetch(url, options); const opts = Object.assign({ credentials: 'same-origin' }, options);
const response = await fetch(url, opts);
// Check if response has content
// 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'); const contentType = response.headers.get('content-type');
let data = null; let data = null;
if (contentType && contentType.includes('application/json')) { if (contentType && contentType.includes('application/json')) {
const text = await response.text(); const text = await response.text();
if (text.trim()) { if (text.trim()) {
data = JSON.parse(text); data = JSON.parse(text);
} }
} }
if (!response.ok) { if (!response.ok) {
throw new Error(data?.error || `HTTP ${response.status}: ${response.statusText}`); throw new Error(data?.error || `HTTP ${response.status}: ${response.statusText}`);
} }
return data; return data;
} catch (error) { } catch (error) {
showMessage('Fehler: ' + error.message, 'error'); showMessage('Fehler: ' + error.message, 'error');
@@ -215,8 +275,8 @@ async function loadAnbieter() {
tarifAnbieterSelect.innerHTML = '<option value="">Anbieter wählen...</option>'; tarifAnbieterSelect.innerHTML = '<option value="">Anbieter wählen...</option>';
anbieter.forEach(a => { anbieter.forEach(a => {
anbieterSelect.innerHTML += `<option value="${a.id}">${a.name}</option>`; anbieterSelect.innerHTML += `<option value="${a.id}">${escapeHtml(a.name)}</option>`;
tarifAnbieterSelect.innerHTML += `<option value="${a.id}">${a.name}</option>`; tarifAnbieterSelect.innerHTML += `<option value="${a.id}">${escapeHtml(a.name)}</option>`;
}); });
} catch (error) { } catch (error) {
@@ -242,7 +302,7 @@ async function loadTarife() {
tarifSelect.innerHTML = '<option value="">Bitte wählen...</option>'; tarifSelect.innerHTML = '<option value="">Bitte wählen...</option>';
tarife.forEach(t => { tarife.forEach(t => {
tarifSelect.innerHTML += `<option value="${t.id}">${t.name}</option>`; tarifSelect.innerHTML += `<option value="${t.id}">${escapeHtml(t.name)}</option>`;
}); });
} catch (error) { } catch (error) {
@@ -295,9 +355,9 @@ function displayTarifoptionen(tarifoptionen) {
<label style="display: flex; align-items: flex-start; gap: 8px; cursor: pointer;"> <label style="display: flex; align-items: flex-start; gap: 8px; cursor: pointer;">
<input type="checkbox" name="tarifoptionen" value="${option.id}" style="margin-top: 2px;"> <input type="checkbox" name="tarifoptionen" value="${option.id}" style="margin-top: 2px;">
<div> <div>
<div style="font-weight: bold; margin-bottom: 4px;">${option.name}</div> <div style="font-weight: bold; margin-bottom: 4px;">${escapeHtml(option.name)}</div>
${option.code ? `<div style="font-size: 0.9em; color: #666; margin-bottom: 4px;">Code: ${option.code}</div>` : ''} ${option.code ? `<div style="font-size: 0.9em; color: #666; margin-bottom: 4px;">Code: ${escapeHtml(option.code)}</div>` : ''}
${option.beschreibung ? `<div style="font-size: 0.8em; color: #555;">${option.beschreibung}</div>` : ''} ${option.beschreibung ? `<div style="font-size: 0.8em; color: #555;">${escapeHtml(option.beschreibung)}</div>` : ''}
</div> </div>
</label> </label>
`; `;
@@ -429,12 +489,17 @@ function displayUploadedFile(file) {
const filesDiv = document.getElementById('uploadedFiles'); const filesDiv = document.getElementById('uploadedFiles');
const fileDiv = document.createElement('div'); const fileDiv = document.createElement('div');
fileDiv.className = 'document-item'; fileDiv.className = 'document-item';
fileDiv.innerHTML = ` const span = document.createElement('span');
<span>${file.name} (${formatFileSize(file.size)})</span> span.textContent = `${file.name} (${formatFileSize(file.size)})`;
<button type="button" onclick="removeFile('${file.name}')" class="btn-danger">Entfernen</button> 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); filesDiv.appendChild(fileDiv);
} }
@@ -497,21 +562,31 @@ async function loadSchaltungen() {
schaltungen.forEach(s => { schaltungen.forEach(s => {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.innerHTML = ` row.innerHTML = `
<td>${s.schaltungs_id}</td> <td>${escapeHtml(s.schaltungs_id)}</td>
<td>${formatDate(s.eingabe_datum)}</td> <td>${escapeHtml(formatDate(s.eingabe_datum))}</td>
<td>${formatDate(s.schaltungs_datum)}</td> <td>${escapeHtml(formatDate(s.schaltungs_datum))}</td>
<td>${s.vorname} ${s.nachname}</td> <td>${escapeHtml(s.vorname)} ${escapeHtml(s.nachname)}</td>
<td>${s.firma || '-'}</td> <td>${escapeHtml(s.firma || '-')}</td>
<td>${s.rufnummer}</td> <td>${escapeHtml(s.rufnummer)}</td>
<td>${s.anbieter_name}</td> <td>${escapeHtml(s.anbieter_name)}</td>
<td>${s.tarif_name}</td> <td>${escapeHtml(s.tarif_name)}</td>
<td>${s.hardware}</td> <td>${escapeHtml(s.hardware || '')}</td>
<td><button onclick="showDocuments(${s.id})" class="btn-success">Dokumente</button></td> <td><button data-action="documents" data-id="${s.id}" class="btn-success">Dokumente</button></td>
<td> <td>
<button onclick="editSchaltung(${s.id})" class="btn-success" style="margin-right: 5px;">Bearbeiten</button> <button data-action="edit" data-id="${s.id}" class="btn-success" style="margin-right: 5px;">Bearbeiten</button>
<button onclick="deleteSchaltung(${s.id}, '${s.schaltungs_id}', '${s.vorname} ${s.nachname}')" class="btn-danger">Löschen</button> <button data-action="delete" data-id="${s.id}" data-schaltungs-id="${escapeHtml(s.schaltungs_id)}" data-name="${escapeHtml(s.vorname + ' ' + s.nachname)}" class="btn-danger">Löschen</button>
</td> </td>
`; `;
// 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); tbody.appendChild(row);
}); });
@@ -565,31 +640,38 @@ async function showDocuments(schaltungId) {
// Format upload date // Format upload date
const uploadDate = doc.uploaded_at ? formatDate(doc.uploaded_at) : 'Unbekannt'; const uploadDate = doc.uploaded_at ? formatDate(doc.uploaded_at) : 'Unbekannt';
// Add view button for PDFs const showView = fileExt === 'pdf';
const viewButton = fileExt === 'pdf' const safeName = escapeHtml(doc.original_filename);
? `<button class="view-btn" onclick="viewPdfDocument(${doc.id}, '${doc.original_filename.replace(/'/g, "\\'")}')">👁️ Anzeigen</button>`
: '';
docItem.innerHTML = ` docItem.innerHTML = `
<div class="document-info"> <div class="document-info">
<div class="document-icon">${icon}</div> <div class="document-icon">${icon}</div>
<div class="document-details"> <div class="document-details">
<div class="document-name">${doc.original_filename}</div> <div class="document-name">${safeName}</div>
<div class="document-meta">Größe: ${fileSize} • Hochgeladen: ${uploadDate}</div> <div class="document-meta">Größe: ${escapeHtml(fileSize)} • Hochgeladen: ${escapeHtml(uploadDate)}</div>
</div> </div>
</div> </div>
<div style="display: flex; gap: 8px;"> <div style="display: flex; gap: 8px;">
${viewButton} ${showView ? `<button class="view-btn" data-doc-action="view" data-id="${doc.id}" data-name="${safeName}">👁️ Anzeigen</button>` : ''}
<button class="download-btn" onclick="downloadDocument(${doc.id})"> <button class="download-btn" data-doc-action="download" data-id="${doc.id}">
📥 Download 📥 Download
</button> </button>
<button class="btn-danger" onclick="deleteDocument(${doc.id}, '${doc.original_filename.replace(/'/g, "\\'")}', ${schaltungId})" <button class="btn-danger" data-doc-action="delete" data-id="${doc.id}" data-name="${safeName}" data-schaltung-id="${schaltungId}"
style="background: #dc3545; border-color: #dc3545; color: white; padding: 8px 12px; border-radius: 4px; font-size: 12px;" style="background: #dc3545; border-color: #dc3545; color: white; padding: 8px 12px; border-radius: 4px; font-size: 12px;"
title="Dokument löschen"> title="Dokument löschen">
🗑️ 🗑️
</button> </button>
</div> </div>
`; `;
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); modalBody.appendChild(docItem);
}); });
@@ -760,20 +842,21 @@ async function loadAnbieterAdmin() {
} }
anbieter.forEach(a => { anbieter.forEach(a => {
// Format the date nicely
const createdAt = new Date(a.created_at).toLocaleDateString('de-DE'); const createdAt = new Date(a.created_at).toLocaleDateString('de-DE');
const tr = document.createElement('tr');
const row = ` tr.innerHTML = `
<tr> <td>${a.id}</td>
<td>${a.id}</td> <td>${escapeHtml(a.name)}</td>
<td>${a.name}</td> <td>${escapeHtml(createdAt)}</td>
<td>${createdAt}</td> <td>
<td> <button data-anbieter-action="delete" data-id="${a.id}" data-name="${escapeHtml(a.name)}" class="btn-danger">Löschen</button>
<button onclick="deleteAnbieter(${a.id}, '${a.name}')" class="btn-danger">Löschen</button> </td>
</td>
</tr>
`; `;
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) { } catch (error) {
@@ -807,21 +890,28 @@ async function loadTarifeAdmin() {
tarife.forEach(t => { tarife.forEach(t => {
const provision = `${(t.provision_euro || 0).toFixed(2).replace('.', ',')}€`; const provision = `${(t.provision_euro || 0).toFixed(2).replace('.', ',')}€`;
const tr = document.createElement('tr');
const row = ` tr.id = `tarif-item-${t.id}`;
<tr id="tarif-item-${t.id}"> tr.innerHTML = `
<td>${t.id}</td> <td>${t.id}</td>
<td>${t.anbieter_name || 'Unbekannt'}</td> <td>${escapeHtml(t.anbieter_name || 'Unbekannt')}</td>
<td>${t.name}</td> <td>${escapeHtml(t.name)}</td>
<td>${provision}</td> <td>${escapeHtml(provision)}</td>
<td>${t.beschreibung || '-'}</td> <td>${escapeHtml(t.beschreibung || '-')}</td>
<td> <td>
<button onclick="editTariff(${t.id})" class="btn-success" style="margin-right: 5px;">Bearbeiten</button> <button data-tarif-action="edit" data-id="${t.id}" class="btn-success" style="margin-right: 5px;">Bearbeiten</button>
<button onclick="deleteTariff(${t.id}, '${t.name}')" class="btn-danger">Löschen</button> <button data-tarif-action="delete" data-id="${t.id}" data-name="${escapeHtml(t.name)}" class="btn-danger">Löschen</button>
</td> </td>
</tr>
`; `;
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) { } catch (error) {
@@ -870,7 +960,7 @@ async function loadAnbieterForTarife() {
select.innerHTML = '<option value="">Anbieter wählen...</option>'; select.innerHTML = '<option value="">Anbieter wählen...</option>';
anbieter.forEach(a => { anbieter.forEach(a => {
select.innerHTML += `<option value="${a.id}">${a.name}</option>`; select.innerHTML += `<option value="${a.id}">${escapeHtml(a.name)}</option>`;
}); });
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Anbieter für Tarife:', error); console.error('Fehler beim Laden der Anbieter für Tarife:', error);
@@ -885,7 +975,7 @@ async function loadMitarbeiterAnbieter() {
select.innerHTML = '<option value="">Anbieter wählen...</option>'; select.innerHTML = '<option value="">Anbieter wählen...</option>';
anbieter.forEach(a => { anbieter.forEach(a => {
select.innerHTML += `<option value="${a.id}">${a.name}</option>`; select.innerHTML += `<option value="${a.id}">${escapeHtml(a.name)}</option>`;
}); });
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Anbieter für Mitarbeiter:', error); console.error('Fehler beim Laden der Anbieter für Mitarbeiter:', error);
@@ -900,7 +990,7 @@ async function loadTarifoptionAnbieter() {
select.innerHTML = '<option value="">Anbieter wählen...</option>'; select.innerHTML = '<option value="">Anbieter wählen...</option>';
anbieter.forEach(a => { anbieter.forEach(a => {
select.innerHTML += `<option value="${a.id}">${a.name}</option>`; select.innerHTML += `<option value="${a.id}">${escapeHtml(a.name)}</option>`;
}); });
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Anbieter für Tarifoptionen:', 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}`); const tarife = await apiRequest(`/api/tarife/${a.id}`);
if (tarife.length > 0) { if (tarife.length > 0) {
tarife.forEach(t => { tarife.forEach(t => {
provisionTarifSelect.innerHTML += `<option value="${t.id}">${a.name} - ${t.name}</option>`; provisionTarifSelect.innerHTML += `<option value="${t.id}">${escapeHtml(a.name + ' - ' + t.name)}</option>`;
}); });
} }
} }
@@ -1028,25 +1118,31 @@ async function loadProvisionsregelnAdmin() {
if (regeln.length > 0) { if (regeln.length > 0) {
const providerDiv = document.createElement('div'); const providerDiv = document.createElement('div');
providerDiv.innerHTML = `<h4>${a.name} - ${t.name}</h4>`; const h4 = document.createElement('h4');
h4.textContent = `${a.name} - ${t.name}`;
providerDiv.appendChild(h4);
provisionsregelnList.appendChild(providerDiv); provisionsregelnList.appendChild(providerDiv);
regeln.forEach(r => { regeln.forEach(r => {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'list-item'; item.className = 'list-item';
item.style.marginLeft = '20px'; item.style.marginLeft = '20px';
const typText = r.provision_typ === 'fix' ? `${r.betrag} €` : `${r.betrag} %`; const typText = r.provision_typ === 'fix' ? `${r.betrag} €` : `${r.betrag} %`;
const statusText = r.aktiv ? 'Aktiv' : 'Inaktiv'; const statusText = r.aktiv ? 'Aktiv' : 'Inaktiv';
item.innerHTML = ` item.innerHTML = `
<span>${typText} - ${r.beschreibung || 'Keine Beschreibung'} (${statusText})</span> <span>${escapeHtml(typText)} - ${escapeHtml(r.beschreibung || 'Keine Beschreibung')} (${escapeHtml(statusText)})</span>
<div> <div>
<button onclick="toggleProvisionsregel(${r.id}, ${!r.aktiv})" class="btn-${r.aktiv ? 'danger' : 'success'}"> <button data-regel-action="toggle" data-id="${r.id}" data-aktiv="${!r.aktiv}" class="btn-${r.aktiv ? 'danger' : 'success'}">
${r.aktiv ? 'Deaktivieren' : 'Aktivieren'} ${r.aktiv ? 'Deaktivieren' : 'Aktivieren'}
</button> </button>
</div> </div>
`; `;
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); provisionsregelnList.appendChild(item);
}); });
} }
@@ -1144,7 +1240,7 @@ async function loadGlobalProvisionAnbieter() {
globalProvisionAnbieterSelect.innerHTML = '<option value="">Anbieter wählen...</option>'; globalProvisionAnbieterSelect.innerHTML = '<option value="">Anbieter wählen...</option>';
anbieter.forEach(a => { anbieter.forEach(a => {
globalProvisionAnbieterSelect.innerHTML += `<option value="${a.id}">${a.name}</option>`; globalProvisionAnbieterSelect.innerHTML += `<option value="${a.id}">${escapeHtml(a.name)}</option>`;
}); });
} catch (error) { } catch (error) {
@@ -1166,36 +1262,41 @@ async function loadGlobalProvisionsregelnAdmin() {
if (regeln.length > 0) { if (regeln.length > 0) {
const providerDiv = document.createElement('div'); const providerDiv = document.createElement('div');
providerDiv.innerHTML = `<h4>Globale Regeln für ${a.name}</h4>`; const h4 = document.createElement('h4');
h4.textContent = `Globale Regeln für ${a.name}`;
providerDiv.appendChild(h4);
globalProvisionsregelnList.appendChild(providerDiv); globalProvisionsregelnList.appendChild(providerDiv);
regeln.forEach(r => { regeln.forEach(r => {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'list-item'; item.className = 'list-item';
item.style.marginLeft = '20px'; item.style.marginLeft = '20px';
// Show employee commission instead of shop commission
let displayText = 'Keine Mitarbeiter-Provision definiert'; let displayText = 'Keine Mitarbeiter-Provision definiert';
if (r.verkaeufer_provision_typ && r.verkaeufer_provision_betrag) { if (r.verkaeufer_provision_typ && r.verkaeufer_provision_betrag) {
const mitarbeiterText = r.verkaeufer_provision_typ === 'fix' const mitarbeiterText = r.verkaeufer_provision_typ === 'fix'
? `${r.verkaeufer_provision_betrag} €` ? `${r.verkaeufer_provision_betrag} €`
: `${r.verkaeufer_provision_betrag} %`; : `${r.verkaeufer_provision_betrag} %`;
displayText = `Mitarbeiter: ${mitarbeiterText}`; displayText = `Mitarbeiter: ${mitarbeiterText}`;
if (r.verkaeufer_provision_beschreibung) { if (r.verkaeufer_provision_beschreibung) {
displayText += ` - ${r.verkaeufer_provision_beschreibung}`; displayText += ` - ${r.verkaeufer_provision_beschreibung}`;
} }
} }
const statusText = r.aktiv ? 'Aktiv' : 'Inaktiv'; const statusText = r.aktiv ? 'Aktiv' : 'Inaktiv';
item.innerHTML = ` item.innerHTML = `
<span>${displayText} (${statusText})</span> <span>${escapeHtml(displayText)} (${escapeHtml(statusText)})</span>
<div> <div>
<button onclick="toggleGlobalProvisionsregel(${r.id}, ${!r.aktiv})" class="btn-${r.aktiv ? 'danger' : 'success'}"> <button data-global-action="toggle" data-id="${r.id}" data-aktiv="${!r.aktiv}" class="btn-${r.aktiv ? 'danger' : 'success'}">
${r.aktiv ? 'Deaktivieren' : 'Aktivieren'} ${r.aktiv ? 'Deaktivieren' : 'Aktivieren'}
</button> </button>
</div> </div>
`; `;
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); globalProvisionsregelnList.appendChild(item);
}); });
} }
@@ -1417,7 +1518,7 @@ async function editSchaltung(id) {
editAnbieterSelect.innerHTML = '<option value="">Bitte wählen...</option>'; editAnbieterSelect.innerHTML = '<option value="">Bitte wählen...</option>';
anbieter.forEach(a => { anbieter.forEach(a => {
editAnbieterSelect.innerHTML += `<option value="${a.id}">${a.name}</option>`; editAnbieterSelect.innerHTML += `<option value="${a.id}">${escapeHtml(a.name)}</option>`;
}); });
// Fill modal with schaltung data // Fill modal with schaltung data
@@ -1581,7 +1682,7 @@ async function loadTarifeForEdit() {
tarifSelect.innerHTML = '<option value="">Bitte wählen...</option>'; tarifSelect.innerHTML = '<option value="">Bitte wählen...</option>';
tarife.forEach(t => { tarife.forEach(t => {
tarifSelect.innerHTML += `<option value="${t.id}">${t.name}</option>`; tarifSelect.innerHTML += `<option value="${t.id}">${escapeHtml(t.name)}</option>`;
}); });
// Clear tarifoptionen when anbieter changes // Clear tarifoptionen when anbieter changes
@@ -1716,17 +1817,17 @@ async function loadGeloeschteSchaltungen() {
geloeschte.forEach(s => { geloeschte.forEach(s => {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.innerHTML = ` row.innerHTML = `
<td>${s.schaltungs_id}</td> <td>${escapeHtml(s.schaltungs_id)}</td>
<td>${formatDate(s.eingabe_datum)}</td> <td>${escapeHtml(formatDate(s.eingabe_datum))}</td>
<td>${formatDate(s.schaltungs_datum)}</td> <td>${escapeHtml(formatDate(s.schaltungs_datum))}</td>
<td>${s.vorname} ${s.nachname}</td> <td>${escapeHtml(s.vorname)} ${escapeHtml(s.nachname)}</td>
<td>${s.firma || '-'}</td> <td>${escapeHtml(s.firma || '-')}</td>
<td>${s.rufnummer}</td> <td>${escapeHtml(s.rufnummer)}</td>
<td>${s.anbieter_name}</td> <td>${escapeHtml(s.anbieter_name)}</td>
<td>${s.tarif_name}</td> <td>${escapeHtml(s.tarif_name)}</td>
<td>${s.hardware}</td> <td>${escapeHtml(s.hardware || '')}</td>
<td><strong style="color: #e74c3c;">${s.loeschgrund}</strong></td> <td><strong style="color: #e74c3c;">${escapeHtml(s.loeschgrund)}</strong></td>
<td>${formatDateTime(s.geloescht_am)}</td> <td>${escapeHtml(formatDateTime(s.geloescht_am))}</td>
`; `;
tbody.appendChild(row); tbody.appendChild(row);
}); });
@@ -1775,7 +1876,7 @@ async function updateFilterDropdowns() {
if (anbieterOverview) { if (anbieterOverview) {
anbieterOverview.innerHTML = '<option value="">Alle Anbieter</option>'; anbieterOverview.innerHTML = '<option value="">Alle Anbieter</option>';
anbieter.forEach(a => { anbieter.forEach(a => {
anbieterOverview.innerHTML += `<option value="${a.name}">${a.name}</option>`; anbieterOverview.innerHTML += `<option value="${escapeHtml(a.name)}">${escapeHtml(a.name)}</option>`;
}); });
} }
@@ -1784,7 +1885,7 @@ async function updateFilterDropdowns() {
if (anbieterProvisionen) { if (anbieterProvisionen) {
anbieterProvisionen.innerHTML = '<option value="">Alle Anbieter</option>'; anbieterProvisionen.innerHTML = '<option value="">Alle Anbieter</option>';
anbieter.forEach(a => { anbieter.forEach(a => {
anbieterProvisionen.innerHTML += `<option value="${a.name}">${a.name}</option>`; anbieterProvisionen.innerHTML += `<option value="${escapeHtml(a.name)}">${escapeHtml(a.name)}</option>`;
}); });
} }
@@ -1808,10 +1909,10 @@ async function updateFilterDropdowns() {
const optionValue = t.name; const optionValue = t.name;
if (tarifOverview) { if (tarifOverview) {
tarifOverview.innerHTML += `<option value="${optionValue}">${optionText}</option>`; tarifOverview.innerHTML += `<option value="${escapeHtml(optionValue)}">${escapeHtml(optionText)}</option>`;
} }
if (tarifProvisionen) { if (tarifProvisionen) {
tarifProvisionen.innerHTML += `<option value="${optionValue}">${optionText}</option>`; tarifProvisionen.innerHTML += `<option value="${escapeHtml(optionValue)}">${escapeHtml(optionText)}</option>`;
} }
}); });
} catch (error) { } catch (error) {
@@ -1961,23 +2062,32 @@ function updateSchaltungenTable(schaltungen) {
schaltungen.forEach(s => { schaltungen.forEach(s => {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.innerHTML = ` row.innerHTML = `
<td>${s.schaltungs_id}</td> <td>${escapeHtml(s.schaltungs_id)}</td>
<td>${formatDate(s.eingabe_datum)}</td> <td>${escapeHtml(formatDate(s.eingabe_datum))}</td>
<td>${formatDate(s.schaltungs_datum)}</td> <td>${escapeHtml(formatDate(s.schaltungs_datum))}</td>
<td>${s.vorname} ${s.nachname}</td> <td>${escapeHtml(s.vorname)} ${escapeHtml(s.nachname)}</td>
<td>${s.firma || '-'}</td> <td>${escapeHtml(s.firma || '-')}</td>
<td>${s.rufnummer}</td> <td>${escapeHtml(s.rufnummer)}</td>
<td>${s.anbieter_name}</td> <td>${escapeHtml(s.anbieter_name)}</td>
<td>${s.tarif_name}</td> <td>${escapeHtml(s.tarif_name)}</td>
<td>${s.hardware}</td> <td>${escapeHtml(s.hardware || '')}</td>
<td> <td>
<button onclick="showDocuments(${s.id})" class="btn-success">Dokumente</button> <button data-filt-action="documents" data-id="${s.id}" class="btn-success">Dokumente</button>
</td> </td>
<td> <td>
<button onclick="editSchaltung(${s.id})" class="btn-secondary">Bearbeiten</button> <button data-filt-action="edit" data-id="${s.id}" class="btn-secondary">Bearbeiten</button>
<button onclick="deleteSchaltung(${s.id})" class="btn-danger">Löschen</button> <button data-filt-action="delete" data-id="${s.id}" data-schaltungs-id="${escapeHtml(s.schaltungs_id)}" data-name="${escapeHtml(s.vorname + ' ' + s.nachname)}" class="btn-danger">Löschen</button>
</td> </td>
`; `;
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); tbody.appendChild(row);
}); });
} }
@@ -2010,15 +2120,15 @@ function updateProvisionenTable(schaltungen, totalShopProvision, totalMitarbeite
schaltungen.forEach(s => { schaltungen.forEach(s => {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.innerHTML = ` row.innerHTML = `
<td>${s.schaltungs_id}</td> <td>${escapeHtml(s.schaltungs_id)}</td>
<td>${formatDate(s.schaltungs_datum)}</td> <td>${escapeHtml(formatDate(s.schaltungs_datum))}</td>
<td>${s.vorname} ${s.nachname}</td> <td>${escapeHtml(s.vorname)} ${escapeHtml(s.nachname)}</td>
<td>${s.anbieter_name}</td> <td>${escapeHtml(s.anbieter_name)}</td>
<td>${s.tarif_name}</td> <td>${escapeHtml(s.tarif_name)}</td>
<td><strong>${s.berechnete_shop_provision || '0.00'} €</strong></td> <td><strong>${escapeHtml(s.berechnete_shop_provision || '0.00')} €</strong></td>
<td><strong>${s.berechnete_mitarbeiter_provision || '0.00'} €</strong></td> <td><strong>${escapeHtml(s.berechnete_mitarbeiter_provision || '0.00')} €</strong></td>
`; `;
tbody.appendChild(row); tbody.appendChild(row);
}); });
@@ -2106,7 +2216,7 @@ async function loadAnbieterForTarifoptionen() {
const select = document.getElementById('tarifoptionAnbieter'); const select = document.getElementById('tarifoptionAnbieter');
select.innerHTML = '<option value="">Anbieter wählen...</option>'; select.innerHTML = '<option value="">Anbieter wählen...</option>';
anbieter.forEach(a => { anbieter.forEach(a => {
select.innerHTML += `<option value="${a.id}">${a.name}</option>`; select.innerHTML += `<option value="${a.id}">${escapeHtml(a.name)}</option>`;
}); });
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Anbieter:', error); console.error('Fehler beim Laden der Anbieter:', error);
@@ -2136,7 +2246,7 @@ async function loadTarifeForOptions() {
<div style="margin-bottom: 8px;"> <div style="margin-bottom: 8px;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;"> <label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" value="${tarif.id}" class="tarif-checkbox" style="margin: 0;"> <input type="checkbox" value="${tarif.id}" class="tarif-checkbox" style="margin: 0;">
<span>${tarif.name}</span> <span>${escapeHtml(tarif.name)}</span>
</label> </label>
</div> </div>
`; `;
@@ -2226,15 +2336,14 @@ async function loadTarifeForEditOptions() {
let checkboxHtml = '<div style="padding: 15px;">'; let checkboxHtml = '<div style="padding: 15px;">';
tarife.forEach((tarif, index) => { tarife.forEach((tarif, index) => {
const provisionText = tarif.provision_euro ? ` (${tarif.provision_euro}€)` : '';
checkboxHtml += ` checkboxHtml += `
<div class="edit-tarif-item" style="margin-bottom: 12px; padding: 12px; border-radius: 6px; background: #f8f9fa; border: 1px solid #e9ecef; transition: all 0.2s;" data-tarif-name="${tarif.name.toLowerCase()}"> <div class="edit-tarif-item" style="margin-bottom: 12px; padding: 12px; border-radius: 6px; background: #f8f9fa; border: 1px solid #e9ecef; transition: all 0.2s;" data-tarif-name="${escapeHtml(tarif.name.toLowerCase())}">
<label style="display: flex; align-items: center; gap: 12px; cursor: pointer; margin: 0;"> <label style="display: flex; align-items: center; gap: 12px; cursor: pointer; margin: 0;">
<input type="checkbox" value="${tarif.id}" class="edit-tarif-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #007bff;"> <input type="checkbox" value="${tarif.id}" class="edit-tarif-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #007bff;">
<div style="flex: 1;"> <div style="flex: 1;">
<div style="font-weight: 500; color: #333; margin-bottom: 2px;">${tarif.name}</div> <div style="font-weight: 500; color: #333; margin-bottom: 2px;">${escapeHtml(tarif.name)}</div>
${tarif.beschreibung ? `<div style="font-size: 12px; color: #6c757d;">${tarif.beschreibung}</div>` : ''} ${tarif.beschreibung ? `<div style="font-size: 12px; color: #6c757d;">${escapeHtml(tarif.beschreibung)}</div>` : ''}
${tarif.provision_euro ? `<div style="font-size: 12px; color: #28a745; font-weight: 500;">Provision: ${tarif.provision_euro}€</div>` : ''} ${tarif.provision_euro ? `<div style="font-size: 12px; color: #28a745; font-weight: 500;">Provision: ${escapeHtml(String(tarif.provision_euro))}€</div>` : ''}
</div> </div>
</label> </label>
</div> </div>
@@ -2336,7 +2445,7 @@ async function loadAnbieterForAddModal() {
const anbieterSelect = document.getElementById('addTarifoptionAnbieter'); const anbieterSelect = document.getElementById('addTarifoptionAnbieter');
anbieterSelect.innerHTML = '<option value="">Anbieter wählen...</option>'; anbieterSelect.innerHTML = '<option value="">Anbieter wählen...</option>';
anbieter.forEach(a => { anbieter.forEach(a => {
anbieterSelect.innerHTML += `<option value="${a.id}">${a.name}</option>`; anbieterSelect.innerHTML += `<option value="${a.id}">${escapeHtml(a.name)}</option>`;
}); });
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Anbieter:', error); console.error('Fehler beim Laden der Anbieter:', error);
@@ -2388,13 +2497,13 @@ async function loadTarifeForAddOptions() {
let checkboxHtml = '<div style="padding: 15px;">'; let checkboxHtml = '<div style="padding: 15px;">';
tarife.forEach((tarif, index) => { tarife.forEach((tarif, index) => {
checkboxHtml += ` checkboxHtml += `
<div class="add-tarif-item" style="margin-bottom: 12px; padding: 12px; border-radius: 6px; background: #f8f9fa; border: 1px solid #e9ecef; transition: all 0.2s;" data-tarif-name="${tarif.name.toLowerCase()}"> <div class="add-tarif-item" style="margin-bottom: 12px; padding: 12px; border-radius: 6px; background: #f8f9fa; border: 1px solid #e9ecef; transition: all 0.2s;" data-tarif-name="${escapeHtml(tarif.name.toLowerCase())}">
<label style="display: flex; align-items: center; gap: 12px; cursor: pointer; margin: 0;"> <label style="display: flex; align-items: center; gap: 12px; cursor: pointer; margin: 0;">
<input type="checkbox" value="${tarif.id}" class="add-tarif-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #28a745;"> <input type="checkbox" value="${tarif.id}" class="add-tarif-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #28a745;">
<div style="flex: 1;"> <div style="flex: 1;">
<div style="font-weight: 500; color: #333; margin-bottom: 2px;">${tarif.name}</div> <div style="font-weight: 500; color: #333; margin-bottom: 2px;">${escapeHtml(tarif.name)}</div>
${tarif.beschreibung ? `<div style="font-size: 12px; color: #6c757d;">${tarif.beschreibung}</div>` : ''} ${tarif.beschreibung ? `<div style="font-size: 12px; color: #6c757d;">${escapeHtml(tarif.beschreibung)}</div>` : ''}
${tarif.provision_euro ? `<div style="font-size: 12px; color: #28a745; font-weight: 500;">Provision: ${tarif.provision_euro}€</div>` : ''} ${tarif.provision_euro ? `<div style="font-size: 12px; color: #28a745; font-weight: 500;">Provision: ${escapeHtml(String(tarif.provision_euro))}€</div>` : ''}
</div> </div>
</label> </label>
</div> </div>
@@ -2627,37 +2736,49 @@ async function displayTarifoptionenAdmin() {
optionen.forEach(option => { optionen.forEach(option => {
const provision = `${(option.provision_euro || 0).toFixed(2).replace('.', ',')}€`; const provision = `${(option.provision_euro || 0).toFixed(2).replace('.', ',')}€`;
// Handle both old and new format // tarifDisplayHtml is the rendered HTML; tarifPlain is the plain-text variant for the title attr.
let tarifDisplay; let tarifDisplayHtml;
let tarifPlain;
if (option.tarif_names) { if (option.tarif_names) {
// New format with multiple tarifs tarifPlain = option.tarif_names || 'Alle Tarife';
tarifDisplay = option.tarif_names || '<em style="color: #28a745;">Alle Tarife</em>'; tarifDisplayHtml = option.tarif_names
? escapeHtml(option.tarif_names)
: '<em style="color: #28a745;">Alle Tarife</em>';
} else { } else {
// Old format with single tarif if (option.tarif_id) {
tarifDisplay = option.tarif_id ? tarifPlain = option.tarif_name || 'Unbekannter Tarif';
(option.tarif_name || 'Unbekannter Tarif') : tarifDisplayHtml = escapeHtml(tarifPlain);
'<em style="color: #28a745;">Alle Tarife</em>'; } else {
tarifPlain = 'Alle Tarife';
tarifDisplayHtml = '<em style="color: #28a745;">Alle Tarife</em>';
}
} }
// For new format, we may not have anbieter_name directly
const anbieterName = option.anbieter_name || 'Mehrere Anbieter'; const anbieterName = option.anbieter_name || 'Mehrere Anbieter';
const row = ` const tr = document.createElement('tr');
<tr> tr.innerHTML = `
<td>${option.id}</td> <td>${option.id}</td>
<td>${anbieterName}</td> <td>${escapeHtml(anbieterName)}</td>
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;" title="${tarifDisplay.replace(/<[^>]*>/g, '')}">${tarifDisplay}</td> <td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(tarifPlain)}">${tarifDisplayHtml}</td>
<td>${option.name}</td> <td>${escapeHtml(option.name)}</td>
<td>${option.code || '-'}</td> <td>${escapeHtml(option.code || '-')}</td>
<td>${provision}</td> <td>${escapeHtml(provision)}</td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis;">${option.beschreibung || '-'}</td> <td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(option.beschreibung || '-')}</td>
<td> <td>
<button onclick="editTarifoption(${option.id})" class="btn-success" style="margin-right: 5px;">Bearbeiten</button> <button data-to-action="edit" data-id="${option.id}" class="btn-success" style="margin-right: 5px;">Bearbeiten</button>
<button onclick="deleteTarifoption(${option.id})" class="btn-danger">Löschen</button> <button data-to-action="delete" data-id="${option.id}" class="btn-danger">Löschen</button>
</td> </td>
</tr>
`; `;
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) { } catch (error) {
console.error('Fehler beim Laden der Tarifoptionen:', error); console.error('Fehler beim Laden der Tarifoptionen:', error);
@@ -2749,7 +2870,7 @@ async function editTarifoption(optionId) {
const anbieterSelect = document.getElementById('editTarifoptionAnbieter'); const anbieterSelect = document.getElementById('editTarifoptionAnbieter');
anbieterSelect.innerHTML = '<option value="">Anbieter wählen...</option>'; anbieterSelect.innerHTML = '<option value="">Anbieter wählen...</option>';
anbieter.forEach(a => { anbieter.forEach(a => {
anbieterSelect.innerHTML += `<option value="${a.id}">${a.name}</option>`; anbieterSelect.innerHTML += `<option value="${a.id}">${escapeHtml(a.name)}</option>`;
}); });
// Try to get from new API first, fallback to old API // Try to get from new API first, fallback to old API
@@ -2949,16 +3070,16 @@ async function loadMitarbeiterProzentsaetzeAdmin() {
providerDiv.className = 'admin-card'; providerDiv.className = 'admin-card';
providerDiv.innerHTML = ` providerDiv.innerHTML = `
<div class="admin-card-header"> <div class="admin-card-header">
<h4>${a.name}</h4> <h4>${escapeHtml(a.name)}</h4>
</div> </div>
<div class="admin-card-content"> <div class="admin-card-content">
<div class="prozentsaetze-list" id="prozentsaetze-${a.id}"></div> <div class="prozentsaetze-list" id="prozentsaetze-${a.id}"></div>
</div> </div>
`; `;
container.appendChild(providerDiv); container.appendChild(providerDiv);
const prozentsaetzeList = document.getElementById(`prozentsaetze-${a.id}`); const prozentsaetzeList = document.getElementById(`prozentsaetze-${a.id}`);
if (prozentsaetze.length === 0) { if (prozentsaetze.length === 0) {
prozentsaetzeList.innerHTML = '<p class="text-muted">Keine Mitarbeiter-Prozentsätze definiert</p>'; prozentsaetzeList.innerHTML = '<p class="text-muted">Keine Mitarbeiter-Prozentsätze definiert</p>';
} else { } else {
@@ -2967,16 +3088,19 @@ async function loadMitarbeiterProzentsaetzeAdmin() {
item.className = 'list-item'; item.className = 'list-item';
item.innerHTML = ` item.innerHTML = `
<div> <div>
<strong>${p.prozentsatz}%</strong> <strong>${escapeHtml(String(p.prozentsatz))}%</strong>
${p.beschreibung ? `- ${p.beschreibung}` : ''} ${p.beschreibung ? `- ${escapeHtml(p.beschreibung)}` : ''}
<br><small class="text-muted">Gültig ab: ${p.gueltig_ab}</small> <br><small class="text-muted">Gültig ab: ${escapeHtml(p.gueltig_ab)}</small>
</div> </div>
<div> <div>
<button onclick="deleteMitarbeiterProzentsatz(${p.id})" class="btn-danger"> <button data-mp-action="delete" data-id="${p.id}" class="btn-danger">
Löschen Löschen
</button> </button>
</div> </div>
`; `;
item.querySelector('button[data-mp-action]').addEventListener('click', (e) => {
deleteMitarbeiterProzentsatz(parseInt(e.currentTarget.dataset.id, 10));
});
prozentsaetzeList.appendChild(item); prozentsaetzeList.appendChild(item);
}); });
} }
@@ -2995,7 +3119,7 @@ async function loadMitarbeiterAnbieter() {
select.innerHTML = '<option value="">Anbieter wählen...</option>'; select.innerHTML = '<option value="">Anbieter wählen...</option>';
anbieter.forEach(a => { anbieter.forEach(a => {
select.innerHTML += `<option value="${a.id}">${a.name}</option>`; select.innerHTML += `<option value="${a.id}">${escapeHtml(a.name)}</option>`;
}); });
} catch (error) { } catch (error) {
@@ -3081,21 +3205,21 @@ async function loadTarifProvisionsAdmin() {
tarifDiv.className = 'admin-card'; tarifDiv.className = 'admin-card';
tarifDiv.innerHTML = ` tarifDiv.innerHTML = `
<div class="admin-card-header"> <div class="admin-card-header">
<h4>${tarif.name} (${tarif.anbieter_name})</h4> <h4>${escapeHtml(tarif.name)} (${escapeHtml(tarif.anbieter_name)})</h4>
<span class="text-muted">Basis-Provision: ${tarif.provision_euro}€</span> <span class="text-muted">Basis-Provision: ${escapeHtml(String(tarif.provision_euro))}€</span>
</div> </div>
<div class="admin-card-content"> <div class="admin-card-content">
<div class="tarif-provision-controls"> <div class="tarif-provision-controls">
<div class="form-row"> <div class="form-row">
<label>Mitarbeiter-Provision (€):</label> <label>Mitarbeiter-Provision (€):</label>
<input type="number" <input type="number"
id="provision-${tarif.id}" id="provision-${tarif.id}"
placeholder="0.00" placeholder="0.00"
step="0.01" step="0.01"
min="0" min="0"
value="${tarif.mitarbeiter_provision || ''}" value="${escapeHtml(String(tarif.mitarbeiter_provision || ''))}"
style="width: 120px;"> style="width: 120px;">
<button onclick="saveTarifProvision(${tarif.id})" class="btn-success"> <button data-tp-action="save" data-id="${tarif.id}" class="btn-success">
Speichern Speichern
</button> </button>
</div> </div>
@@ -3105,6 +3229,9 @@ async function loadTarifProvisionsAdmin() {
</div> </div>
</div> </div>
`; `;
tarifDiv.querySelector('button[data-tp-action]').addEventListener('click', (e) => {
saveTarifProvision(parseInt(e.currentTarget.dataset.id, 10));
});
container.appendChild(tarifDiv); container.appendChild(tarifDiv);
} }
+81
View File
@@ -0,0 +1,81 @@
const express = require('express');
const db = require('../db');
const asyncHandler = require('../middleware/asyncHandler');
const router = express.Router();
router.get('/', asyncHandler(async (req, res) => {
const rows = await db.all('SELECT * FROM anbieter ORDER BY name');
res.json(rows);
}));
router.post('/', asyncHandler(async (req, res) => {
const { name } = req.body || {};
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Name ist erforderlich' });
}
try {
const result = await db.run('INSERT INTO anbieter (name) VALUES (?)', [name.trim()]);
res.json({ id: result.lastID, name: name.trim() });
} catch (err) {
if (/UNIQUE/.test(err.message)) {
return res.status(400).json({ error: 'Anbieter mit diesem Namen existiert bereits' });
}
throw err;
}
}));
router.delete('/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const [tariffeRow, schaltungenRow] = await Promise.all([
db.get('SELECT COUNT(*) AS n FROM tarife WHERE anbieter_id = ?', [id]),
db.get('SELECT COUNT(*) AS n FROM schaltungen WHERE anbieter_id = ?', [id]),
]);
if (tariffeRow.n + schaltungenRow.n > 0) {
const reasons = [];
if (tariffeRow.n > 0) reasons.push(`${tariffeRow.n} Tarif(e)`);
if (schaltungenRow.n > 0) reasons.push(`${schaltungenRow.n} Schaltung(en)`);
return res.status(400).json({
error: `Anbieter kann nicht gelöscht werden: ${reasons.join(', ')} verwenden diesen Anbieter`,
});
}
const deletedRules = await db.run('DELETE FROM anbieter_provisionsregeln WHERE anbieter_id = ?', [id]);
const result = await db.run('DELETE FROM anbieter WHERE id = ?', [id]);
if (result.changes === 0) {
return res.status(404).json({ error: 'Anbieter nicht gefunden' });
}
let message = 'Anbieter erfolgreich gelöscht';
if (deletedRules.changes > 0) {
message += ` (${deletedRules.changes} Provisionsregel(n) wurden mit gelöscht)`;
}
res.json({ message });
}));
// Global commission rules for this provider
router.get('/:id/provisionsregeln', asyncHandler(async (req, res) => {
const { id } = req.params;
const rows = await db.all(
`SELECT apr.*, a.name AS anbieter_name
FROM anbieter_provisionsregeln apr
JOIN anbieter a ON apr.anbieter_id = a.id
WHERE apr.anbieter_id = ? AND apr.aktiv = 1
ORDER BY apr.gueltig_ab DESC`,
[id]
);
res.json(rows);
}));
// Mitarbeiter-Prozentsätze for this provider
router.get('/:id/mitarbeiter-prozentsaetze', asyncHandler(async (req, res) => {
const { id } = req.params;
const rows = await db.all(
'SELECT * FROM mitarbeiter_prozentsaetze WHERE anbieter_id = ? ORDER BY gueltig_ab DESC',
[id]
);
res.json(rows);
}));
module.exports = router;
+120
View File
@@ -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;
+105
View File
@@ -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;
+34
View File
@@ -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;
+151
View File
@@ -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;
+172
View File
@@ -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;
+217
View File
@@ -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;
+123
View File
@@ -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;
+205
View File
@@ -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;
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env node
// CLI: create-user <username> <password> [--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 -- <username> <password> [--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);
});
+116 -2086
View File
File diff suppressed because it is too large. Load diff
+138
View File
@@ -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);
});