Files
prov/database.js
T
kerimatilganandClaude Opus 4.7 f2ab23f261 Add VO management, tariff-option assignment, and 2-step schaltung wizard
VOs (Vertriebspartner-Nummern)
- New vos table (one row per VO, scoped to a provider). Migrations
  add tarife.vo_id and schaltungen.vo_id columns (nullable so legacy
  rows survive).
- New /api/vos CRUD (list, create, update, delete with use-checks).
- Admin: new "VOs (Vertriebspartner)" sub-tab. List/create with
  provider dropdown; edit via prompt; delete refuses while used.
- /api/tarife?vo_id=X filter for fetching tariffs of a specific VO.

Tariff editor
- Add-tariff form requires a VO; new dependent VO dropdown that
  populates after the provider is chosen.
- Edit-tariff modal now shows the VO dropdown and a checkbox grid of
  all anbieter-wide tarifoptionen. Saving the dialog applies the
  selection via the existing tarifoptionen_tarife table.
- New GET /api/tarife/:id/tarifoptionen-multi returns the option set
  scoped to the tariff's provider, each annotated with whether it is
  currently assigned to this tariff.
- POST/PUT /api/tarife accept vo_id (required for create, validated
  against the provider) and an optional tarifoption_ids array.

Schaltung wizard
- The Schaltung-erfassen tab is now a 2-step wizard:
  step 1 picks a provider from a button row (mirrors the screenshot
  the user shared); step 2 shows a 2-column form mirroring the
  Aktivierungs-/Vertragsdaten layout.
- Step 2 cascades: VO -> tariffs filtered by VO -> tarifoptionen
  filtered by tariff. Erfasser is autofilled from the logged-in user
  (read-only).
- Removed customer fields that the user does not need (Kundennummer,
  Kennwort/Service-PIN never existed; Rufnummer is now optional both
  in the form and in the API validation).
- After saving, the wizard resets to step 1 ready for the next entry.

Schema
- tarife: vo_id added.
- schaltungen: vo_id added; rufnummer is nullable in new DBs.
  Existing DBs may still have NOT NULL on rufnummer; the schaltungen
  route now sends an empty string instead of NULL to remain
  compatible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:36:22 +02:00

327 lines
14 KiB
JavaScript

const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const dbPath = path.join(__dirname, 'provision.db');
let dbInstance = null;
function getDatabase() {
if (!dbInstance) {
dbInstance = new sqlite3.Database(dbPath, (err) => {
if (err) {
console.error('Error opening database:', err.message);
dbInstance = null;
throw err;
}
console.log('Connected to SQLite database');
});
dbInstance.configure('busyTimeout', 3000);
}
return dbInstance;
}
function enableForeignKeys(db) {
db.run('PRAGMA foreign_keys = ON', (err) => {
if (err) {
console.error('Error enabling foreign keys:', err.message);
}
});
}
function closeDatabase() {
if (dbInstance) {
dbInstance.close((err) => {
if (err) {
console.error('Error closing database:', err.message);
} else {
console.log('Database connection closed');
}
dbInstance = null;
});
}
}
// Best-effort ALTER TABLE: ignores "duplicate column" errors but logs others
function addColumnIfMissing(db, table, columnDef) {
db.run(`ALTER TABLE ${table} ADD COLUMN ${columnDef}`, (err) => {
if (err && !/duplicate column name/i.test(err.message)) {
console.error(`ALTER TABLE ${table} ADD ${columnDef}:`, err.message);
}
});
}
function initDatabase() {
const db = getDatabase();
db.serialize(() => {
// Anbieter (Providers)
db.run(`CREATE TABLE IF NOT EXISTS anbieter (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Mitarbeiter-Prozentsätze pro Anbieter
db.run(`CREATE TABLE IF NOT EXISTS mitarbeiter_prozentsaetze (
id INTEGER PRIMARY KEY AUTOINCREMENT,
anbieter_id INTEGER NOT NULL,
prozentsatz DECIMAL(5,2) NOT NULL DEFAULT 0,
gueltig_ab DATE DEFAULT CURRENT_DATE,
beschreibung TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (anbieter_id) REFERENCES anbieter (id),
UNIQUE(anbieter_id, gueltig_ab)
)`);
// Vertriebspartner-Nummern (VOs) per Anbieter. Each tariff is assigned to
// exactly one VO (nullable for legacy rows that pre-date this column).
db.run(`CREATE TABLE IF NOT EXISTS vos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
anbieter_id INTEGER NOT NULL,
nummer TEXT NOT NULL,
bezeichnung TEXT,
aktiv INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (anbieter_id) REFERENCES anbieter (id),
UNIQUE(anbieter_id, nummer)
)`);
// Tarife
db.run(`CREATE TABLE IF NOT EXISTS tarife (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
anbieter_id INTEGER,
vo_id INTEGER,
provision_euro DECIMAL(10,2) DEFAULT 0,
mitarbeiter_provision DECIMAL(10,2) DEFAULT 0,
beschreibung TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (anbieter_id) REFERENCES anbieter (id),
FOREIGN KEY (vo_id) REFERENCES vos (id),
UNIQUE(name, anbieter_id)
)`);
addColumnIfMissing(db, 'tarife', 'mitarbeiter_provision DECIMAL(10,2) DEFAULT 0');
addColumnIfMissing(db, 'tarife', 'vo_id INTEGER');
// Tariff-specific commission rules. basis_euro: base amount for percent calculation.
db.run(`CREATE TABLE IF NOT EXISTS provisionsregeln (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tarif_id INTEGER NOT NULL,
provision_typ TEXT NOT NULL CHECK(provision_typ IN ('fix', 'prozent')),
betrag DECIMAL(10,2) NOT NULL,
basis_euro DECIMAL(10,2),
beschreibung TEXT,
verkaeufer_provision_typ TEXT CHECK(verkaeufer_provision_typ IN ('fix', 'prozent')),
verkaeufer_provision_betrag DECIMAL(10,2),
verkaeufer_provision_beschreibung TEXT,
gueltig_ab DATE DEFAULT CURRENT_DATE,
aktiv BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tarif_id) REFERENCES tarife (id)
)`);
addColumnIfMissing(db, 'provisionsregeln', 'basis_euro DECIMAL(10,2)');
// Global provider commission rules
db.run(`CREATE TABLE IF NOT EXISTS anbieter_provisionsregeln (
id INTEGER PRIMARY KEY AUTOINCREMENT,
anbieter_id INTEGER NOT NULL,
provision_typ TEXT NOT NULL CHECK(provision_typ IN ('fix', 'prozent')),
betrag DECIMAL(10,2) NOT NULL,
basis_euro DECIMAL(10,2),
beschreibung TEXT,
verkaeufer_provision_typ TEXT CHECK(verkaeufer_provision_typ IN ('fix', 'prozent')),
verkaeufer_provision_betrag DECIMAL(10,2),
verkaeufer_provision_beschreibung TEXT,
gueltig_ab DATE DEFAULT CURRENT_DATE,
aktiv BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (anbieter_id) REFERENCES anbieter (id)
)`);
addColumnIfMissing(db, 'anbieter_provisionsregeln', 'basis_euro DECIMAL(10,2)');
// Schaltungen. rufnummer is nullable on new installs (the application
// also accepts NULL on existing DBs with NOT NULL columns since we just
// pass empty string when missing). vo_id captures which VO was used.
db.run(`CREATE TABLE IF NOT EXISTS schaltungen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
schaltungs_id TEXT NOT NULL UNIQUE,
eingabe_datum DATE NOT NULL,
schaltungs_datum DATE NOT NULL,
vorname TEXT NOT NULL,
nachname TEXT NOT NULL,
firma TEXT,
rufnummer TEXT,
anbieter_id INTEGER NOT NULL,
vo_id INTEGER,
tarif_id INTEGER NOT NULL,
hardware TEXT,
hardware_ek DECIMAL(10,2),
kundenverkaufspreis_netto DECIMAL(10,2),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (anbieter_id) REFERENCES anbieter (id),
FOREIGN KEY (tarif_id) REFERENCES tarife (id)
)`);
addColumnIfMissing(db, 'schaltungen', 'hardware_ek DECIMAL(10,2)');
addColumnIfMissing(db, 'schaltungen', 'kundenverkaufspreis_netto DECIMAL(10,2)');
addColumnIfMissing(db, 'schaltungen', 'vo_id INTEGER');
// Dokumente (note: ON DELETE CASCADE only applied to new DBs - existing DBs
// handled via explicit DELETE in server.js)
db.run(`CREATE TABLE IF NOT EXISTS dokumente (
id INTEGER PRIMARY KEY AUTOINCREMENT,
schaltung_id INTEGER NOT NULL,
filename TEXT NOT NULL,
original_filename TEXT NOT NULL,
filepath TEXT NOT NULL,
filesize INTEGER,
mimetype TEXT,
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (schaltung_id) REFERENCES schaltungen (id) ON DELETE CASCADE
)`);
// Soft-deleted schaltungen
db.run(`CREATE TABLE IF NOT EXISTS geloeschte_schaltungen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
original_id INTEGER NOT NULL,
schaltungs_id TEXT NOT NULL,
eingabe_datum DATE NOT NULL,
schaltungs_datum DATE NOT NULL,
vorname TEXT NOT NULL,
nachname TEXT NOT NULL,
firma TEXT,
rufnummer TEXT NOT NULL,
anbieter_id INTEGER NOT NULL,
tarif_id INTEGER NOT NULL,
hardware TEXT,
anbieter_name TEXT NOT NULL,
tarif_name TEXT NOT NULL,
loeschgrund TEXT NOT NULL,
geloescht_am DATETIME DEFAULT CURRENT_TIMESTAMP,
geloescht_von TEXT DEFAULT 'System'
)`);
// Tarifoptionen - schema matches production: anbieter_id required, tarif_id optional
// (NULL = anbieter-wide), unique per (anbieter_id, tarif_id, name).
db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
anbieter_id INTEGER NOT NULL,
tarif_id INTEGER,
name TEXT NOT NULL,
code TEXT,
provision_euro DECIMAL(10,2) DEFAULT 0,
beschreibung TEXT,
aktiv BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (anbieter_id) REFERENCES anbieter (id),
FOREIGN KEY (tarif_id) REFERENCES tarife (id),
UNIQUE(anbieter_id, tarif_id, name)
)`);
// M:N variant kept for the /multi endpoints
db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
code TEXT,
provision_euro DECIMAL(10,2) DEFAULT 0,
beschreibung TEXT,
aktiv BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen_tarife (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tarifoption_id INTEGER NOT NULL,
tarif_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tarifoption_id) REFERENCES tarifoptionen_new (id) ON DELETE CASCADE,
FOREIGN KEY (tarif_id) REFERENCES tarife (id) ON DELETE CASCADE,
UNIQUE(tarifoption_id, tarif_id)
)`);
// Link table: which tarifoptionen are assigned to which schaltung
db.run(`CREATE TABLE IF NOT EXISTS schaltungen_tarifoptionen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
schaltung_id INTEGER NOT NULL,
tarifoption_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (schaltung_id) REFERENCES schaltungen (id) ON DELETE CASCADE,
FOREIGN KEY (tarifoption_id) REFERENCES tarifoptionen (id),
UNIQUE(schaltung_id, tarifoption_id)
)`);
// Users for authentication. uuid is the stable external identifier exposed
// through the API so usernames can be renamed without breaking references.
db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT UNIQUE,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
addColumnIfMissing(db, 'users', 'uuid TEXT');
// Backfill UUIDs for rows that pre-date the column.
db.all('SELECT id FROM users WHERE uuid IS NULL OR uuid = ""', (err, rows) => {
if (err) {
console.error('Backfill uuid lookup failed:', err.message);
return;
}
if (!rows || rows.length === 0) return;
const { randomUUID } = require('crypto');
for (const row of rows) {
db.run('UPDATE users SET uuid = ? WHERE id = ?', [randomUUID(), row.id], (uerr) => {
if (uerr) console.error('Backfill uuid update failed:', uerr.message);
});
}
});
// Seed data (idempotent)
db.run(`INSERT OR IGNORE INTO anbieter (name) VALUES
('Telekom'),
('Vodafone'),
('O2'),
('1&1')`);
db.run(`INSERT OR IGNORE INTO tarife (name, anbieter_id, provision_euro, beschreibung) VALUES
('MagentaMobil S', 1, 150.00, 'Telekom Basis-Tarif'),
('MagentaMobil M', 1, 200.00, 'Telekom Standard-Tarif'),
('MagentaMobil L', 1, 250.00, 'Telekom Premium-Tarif'),
('VF green LTE 10GB mit Smartphone 5', 2, 190.00, 'Vodafone Standard-Paket'),
('Red S', 2, 120.00, 'Vodafone Einsteiger-Tarif'),
('Red M', 2, 160.00, 'Vodafone Standard-Tarif'),
('Red L', 2, 200.00, 'Vodafone Premium-Tarif'),
('o2 Free S', 3, 100.00, 'O2 Basis-Tarif'),
('o2 Free M', 3, 140.00, 'O2 Standard-Tarif'),
('o2 Free L', 3, 180.00, 'O2 Premium-Tarif')`);
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')`);
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')`);
// Seed tarifoptionen (correct schema: anbieter_id required)
db.run(`INSERT OR IGNORE INTO tarifoptionen (anbieter_id, tarif_id, name, code, provision_euro, beschreibung) VALUES
(1, 1, 'Magenta Premium Option', 'MAG-PREM', 50.00, 'Premium-Zusatz für Telekom'),
(1, 1, 'Aktionscode Neukunde', 'NEU2024', 30.00, 'Neukunden-Bonus'),
(2, 4, '24 x 17€ MGP Rabatt GK A3958457', 'A3958457', -120.00, 'Vodafone Rabatt-Aktion'),
(2, 5, 'Red M Aktion Q1', 'RED-M-Q1', 25.00, 'Quartals-Aktion'),
(2, 6, 'Red L Premium Paket', 'RED-L-PREM', 35.00, 'Premium-Paket')`);
db.run(`INSERT OR IGNORE INTO provisionsregeln (tarif_id, provision_typ, betrag, beschreibung) VALUES
(1, 'fix', 25.00, 'Feste Provision für MagentaMobil S'),
(2, 'fix', 35.00, 'Feste Provision für MagentaMobil M'),
(3, 'fix', 45.00, 'Feste Provision für MagentaMobil L')`);
});
enableForeignKeys(db);
}
module.exports = { initDatabase, getDatabase, closeDatabase };