aether-framework/resources/[aether]/aether-police/server/module/personal.lua
Jerrit Fritzsche e4b82dd685 Aether-Framework: Fachmodule, Admin-Panel und Docker-Datenbank
- aether-medical: Stationen/Belegung, Intensivstation, Blutbank, Fuhrpark,
  Nachrichten/Aushänge, Berichte und Textbausteine (server + NUI)
- aether-admin: vollständiges Server-Admin-Panel als echtes FiveM-Resource,
  an aether-core gekoppelt (Rechte über aether_users.admin_group), mit
  WebRTC-Live-Kameras (Bildschirmfreigabe) und separatem Media-Server (SFU)
- Prototypen admin/ vervollständigt
- SQL-Schema aether_admin.sql
- Docker-Compose mit MariaDB und idempotenter Auto-Migration (schema_migrations)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 18:55:32 +02:00

565 lines
22 KiB
Lua

-- =====================================================================
-- aether-police / Modul: Personalverwaltung
--
-- Rollen und Einzelrechte werden hier vergeben. Es gelten drei feste
-- Schranken, die sich nicht umgehen lassen:
-- 1. Niemand verwaltet Gleichrangige oder Höherstehende.
-- 2. Niemand vergibt eine Rolle auf oder über dem eigenen Rang.
-- 3. Niemand vergibt ein Recht, das er selbst nicht besitzt.
-- Zusätzlich kann sich niemand selbst bearbeiten.
-- =====================================================================
Kern.RegistriereModul({
id = 'personal', name = 'Personal', icon = '👮',
reihenfolge = 11, recht = 'personal.sehen',
beschreibung = 'Dienstgrade, Rechte und Personalakten',
})
-- ---------------------------------------------------------------------
-- Hilfsfunktionen
-- ---------------------------------------------------------------------
--- Schlägt die nächste freie Dienstnummer vor
--- @return string
local function NaechsteDienstnummer()
local praefix = Polizei.Config.Kuerzel .. '-'
-- Alle vergebenen Nummern einmal holen, dann die erste Lücke suchen
local vergeben = {}
local zeilen = MySQL.query.await(
'SELECT dienstnummer FROM police_personal WHERE dienstnummer LIKE ?',
{ praefix .. '%' }) or {}
for _, zeile in ipairs(zeilen) do
vergeben[zeile.dienstnummer] = true
end
for nummer = 1, 9999 do
local kandidat = ('%s%04d'):format(praefix, nummer)
if not vergeben[kandidat] then return kandidat end
end
return praefix .. tostring(os.time()):sub(-4)
end
--- Wandelt eine JSON-Liste in eine Menge um
local function ZuMenge(roh)
local menge = {}
if not roh then return menge end
local erfolg, liste = pcall(json.decode, roh)
if erfolg and type(liste) == 'table' then
for _, wert in ipairs(liste) do menge[wert] = true end
end
return menge
end
--- Wandelt eine Menge zurück in eine sortierte Liste
local function ZuListe(menge)
local liste = {}
for wert in pairs(menge) do liste[#liste + 1] = wert end
table.sort(liste)
return liste
end
--- Prüft, ob ein Beamter einen anderen Personaldatensatz verwalten darf
--- @param beamter table Handelnder
--- @param ziel table Zeile aus police_personal
--- @return boolean, string|nil
local function DarfVerwalten(beamter, ziel)
if ziel.character == beamter.charakterId then
return false, 'Du kannst deinen eigenen Datensatz nicht bearbeiten.'
end
if not Rechte.StehtUeber(beamter, ziel.rolle) then
return false, 'Du kannst nur rangniedrigere Beamte verwalten.'
end
return true
end
-- =====================================================================
-- ÜBERSICHT
-- =====================================================================
Kern.RegistriereAktion('personal', 'liste', nil, function(quelle, beamter, daten)
local bedingungen, werte = { '1=1' }, {}
if daten.ehemalige then
bedingungen[#bedingungen + 1] = 'p.aktiv = 0'
else
bedingungen[#bedingungen + 1] = 'p.aktiv = 1'
end
if daten.abteilung and daten.abteilung ~= '' then
bedingungen[#bedingungen + 1] = 'p.abteilung = ?'
werte[#werte + 1] = daten.abteilung
end
if daten.rolle and daten.rolle ~= '' then
bedingungen[#bedingungen + 1] = 'p.rolle = ?'
werte[#werte + 1] = daten.rolle
end
if type(daten.begriff) == 'string' and #daten.begriff >= 2 then
bedingungen[#bedingungen + 1] =
'(p.dienstnummer LIKE ? OR c.firstname LIKE ? OR c.lastname LIKE ?)'
local muster = '%' .. daten.begriff .. '%'
werte[#werte + 1] = muster
werte[#werte + 1] = muster
werte[#werte + 1] = muster
end
local personal = MySQL.query.await(([[
SELECT p.`character`, p.dienstnummer, p.rolle, p.abteilung, p.eingestellt,
p.aktiv, p.notiz, p.rechte_extra, p.rechte_entzug,
c.firstname, c.lastname,
(SELECT COALESCE(SUM(d.sekunden), 0) FROM police_dienst d
WHERE d.`character` = p.`character`) AS dienstzeit
FROM police_personal p
JOIN aether_characters c ON c.id = p.`character`
WHERE %s
ORDER BY p.dienstnummer
LIMIT 200
]]):format(table.concat(bedingungen, ' AND ')), werte) or {}
-- Wer ist gerade im Dienst?
local imDienst = {}
for _, eintrag in pairs(Dienst.AlleImDienst()) do
imDienst[eintrag.charakterId] = true
end
for _, eintrag in ipairs(personal) do
local rolle = Polizei.HoleRolle(eintrag.rolle)
eintrag.rolleName = rolle and rolle.name or eintrag.rolle
eintrag.stufe = Polizei.Stufe(eintrag.rolle)
eintrag.imDienst = imDienst[eintrag.character] == true
-- Anzahl abweichender Einzelrechte für die Übersicht
local extra = ZuMenge(eintrag.rechte_extra)
local entzug = ZuMenge(eintrag.rechte_entzug)
local anzahl = 0
for _ in pairs(extra) do anzahl = anzahl + 1 end
for _ in pairs(entzug) do anzahl = anzahl + 1 end
eintrag.abweichungen = anzahl
eintrag.rechte_extra, eintrag.rechte_entzug = nil, nil
end
-- Verteilung nach Rollen
local verteilung = MySQL.query.await(
'SELECT rolle, COUNT(*) AS anzahl FROM police_personal WHERE aktiv = 1 GROUP BY rolle') or {}
return {
personal = personal,
verteilung = verteilung,
rollen = Polizei.Rollen,
abteilungen= Polizei.Config.Abteilungen,
meineStufe = beamter.stufe,
rechte = {
einstellen = Rechte.Hat(beamter, 'personal.einstellen'),
rolle = Rechte.Hat(beamter, 'personal.rolle'),
rechte = Rechte.Hat(beamter, 'personal.rechte'),
},
}
end)
Kern.RegistriereAktion('personal', 'oeffnen', nil, function(quelle, beamter, daten)
local charakterId = tonumber(daten.charakterId)
if not charakterId then return nil, 'Ungültige Kennung.' end
local zeilen = MySQL.query.await([[
SELECT p.*, c.firstname, c.lastname, c.dob
FROM police_personal p
JOIN aether_characters c ON c.id = p.`character`
WHERE p.`character` = ?
]], { charakterId })
if not zeilen or not zeilen[1] then return nil, 'Personaldatensatz nicht gefunden.' end
local eintrag = zeilen[1]
local rolle = Polizei.HoleRolle(eintrag.rolle)
-- Rechte aufschlüsseln: woher kommt welches Recht?
local ausRolle = Polizei.RechteDerRolle(eintrag.rolle)
local extra = ZuMenge(eintrag.rechte_extra)
local entzug = ZuMenge(eintrag.rechte_entzug)
local rechteUebersicht = {}
for _, gruppe in ipairs(Polizei.Rechte) do
local eintraege = {}
for _, recht in ipairs(gruppe.eintraege) do
local herkunft = 'keins'
if entzug[recht.id] then herkunft = 'entzogen'
elseif extra[recht.id] then herkunft = 'zusaetzlich'
elseif ausRolle[recht.id] then herkunft = 'rolle' end
eintraege[#eintraege + 1] = {
id = recht.id, name = recht.name, herkunft = herkunft,
-- Darf der Handelnde dieses Recht überhaupt vergeben?
vergebbar = Rechte.Hat(beamter, recht.id),
}
end
rechteUebersicht[#rechteUebersicht + 1] = { gruppe = gruppe.gruppe, eintraege = eintraege }
end
-- Dienstzeiten
local dienste = MySQL.query.await([[
SELECT beginn, ende, sekunden, abteilung
FROM police_dienst WHERE `character` = ?
ORDER BY id DESC LIMIT 20
]], { charakterId }) or {}
local gesamt = MySQL.scalar.await(
'SELECT COALESCE(SUM(sekunden), 0) FROM police_dienst WHERE `character` = ?',
{ charakterId }) or 0
local darf, grund = DarfVerwalten(beamter, eintrag)
return {
personal = {
charakterId = eintrag.character,
name = eintrag.firstname .. ' ' .. eintrag.lastname,
geburt = tostring(eintrag.dob):sub(1, 10),
dienstnummer= eintrag.dienstnummer,
rolle = eintrag.rolle,
rolleName = rolle and rolle.name or eintrag.rolle,
abteilung = eintrag.abteilung,
eingestellt = eintrag.eingestellt,
aktiv = eintrag.aktiv == 1,
notiz = eintrag.notiz,
},
rechte = rechteUebersicht,
dienste = dienste,
gesamtzeit = gesamt,
rollen = Polizei.Rollen,
abteilungen = Polizei.Config.Abteilungen,
meineStufe = beamter.stufe,
darfVerwalten= darf,
sperrgrund = grund,
meineRechte = {
rolle = Rechte.Hat(beamter, 'personal.rolle'),
rechte = Rechte.Hat(beamter, 'personal.rechte'),
einstellen = Rechte.Hat(beamter, 'personal.einstellen'),
},
}
end)
-- =====================================================================
-- EINSTELLEN
-- =====================================================================
Kern.RegistriereAktion('personal', 'einstellen', 'personal.einstellen', function(quelle, beamter, daten)
local charakterId = tonumber(daten.charakterId)
if not charakterId then return nil, 'Keine Person ausgewählt.' end
local person = MySQL.query.await(
'SELECT firstname, lastname FROM aether_characters WHERE id = ? AND deleted = 0',
{ charakterId })
if not person or not person[1] then return nil, 'Diese Person existiert nicht.' end
-- Bereits im Bestand?
local vorhanden = MySQL.query.await(
'SELECT dienstnummer, aktiv FROM police_personal WHERE `character` = ?', { charakterId })
if vorhanden and vorhanden[1] and vorhanden[1].aktiv == 1 then
return nil, 'Diese Person ist bereits im Personalbestand.'
end
-- Rolle prüfen: niemals auf oder über dem eigenen Rang
local rollenId = tostring(daten.rolle or 'anwaerter')
local rolle = Polizei.HoleRolle(rollenId)
if not rolle then return nil, 'Unbekannte Rolle.' end
if rolle.stufe >= beamter.stufe then
return nil, 'Du kannst niemanden auf deinen Rang oder darüber einstellen.'
end
-- Abteilung prüfen
local abteilung = 'streife'
for _, eintrag in ipairs(Polizei.Config.Abteilungen) do
if eintrag.id == daten.abteilung then abteilung = daten.abteilung break end
end
-- Dienstnummer: eigene Angabe oder Vorschlag
local dienstnummer = type(daten.dienstnummer) == 'string'
and daten.dienstnummer:gsub('%s', ''):upper() or ''
if dienstnummer == '' then dienstnummer = NaechsteDienstnummer() end
if #dienstnummer > 16 then return nil, 'Die Dienstnummer ist zu lang.' end
local belegt = MySQL.scalar.await(
'SELECT `character` FROM police_personal WHERE dienstnummer = ? AND `character` <> ?',
{ dienstnummer, charakterId })
if belegt then return nil, 'Diese Dienstnummer ist bereits vergeben.' end
-- Wiedereinstellung oder Neuanlage
if vorhanden and vorhanden[1] then
MySQL.update.await([[
UPDATE police_personal
SET dienstnummer = ?, rolle = ?, abteilung = ?, aktiv = 1,
rechte_extra = NULL, rechte_entzug = NULL, eingestellt = NOW()
WHERE `character` = ?
]], { dienstnummer, rollenId, abteilung, charakterId })
else
MySQL.insert.await([[
INSERT INTO police_personal (`character`, dienstnummer, rolle, abteilung, notiz)
VALUES (?, ?, ?, ?, ?)
]], { charakterId, dienstnummer, rollenId, abteilung,
type(daten.notiz) == 'string' and daten.notiz:sub(1, 255) or nil })
end
Protokoll.Schreibe(beamter, 'personal', 'Beamten eingestellt',
{ typ = 'person', id = charakterId },
{ dienstnummer = dienstnummer, rolle = rollenId, abteilung = abteilung })
Hinweise.Erstelle(nil, {
kategorie = 'system', prioritaet = 'niedrig',
titel = 'Neuer Kollege',
text = ('%s %s (%s) wurde als %s eingestellt.')
:format(person[1].firstname, person[1].lastname, dienstnummer, rolle.name),
})
Kern.Loese('personal.eingestellt', charakterId, beamter)
return { dienstnummer = dienstnummer }
end)
--- Vorschlag für die nächste Dienstnummer
Kern.RegistriereAktion('personal', 'nummerVorschlag', 'personal.einstellen', function()
return { dienstnummer = NaechsteDienstnummer() }
end)
--- Personensuche für die Einstellung
Kern.RegistriereAktion('personal', 'personSuche', 'personal.einstellen', function(quelle, beamter, daten)
local begriff = type(daten.begriff) == 'string' and daten.begriff or ''
if #begriff < 2 then return { treffer = {} } end
local muster = '%' .. begriff .. '%'
local treffer = MySQL.query.await([[
SELECT c.id, c.firstname, c.lastname, c.dob,
p.dienstnummer, p.aktiv
FROM aether_characters c
LEFT JOIN police_personal p ON p.`character` = c.id
WHERE c.deleted = 0
AND (c.firstname LIKE ? OR c.lastname LIKE ?
OR CONCAT(c.firstname, ' ', c.lastname) LIKE ?)
LIMIT 20
]], { muster, muster, muster }) or {}
return { treffer = treffer }
end)
-- =====================================================================
-- ROLLE UND ABTEILUNG
-- =====================================================================
Kern.RegistriereAktion('personal', 'rolleAendern', 'personal.rolle', function(quelle, beamter, daten)
local charakterId = tonumber(daten.charakterId)
if not charakterId then return nil, 'Ungültige Kennung.' end
local ziel = MySQL.query.await(
'SELECT `character`, rolle, dienstnummer FROM police_personal WHERE `character` = ? AND aktiv = 1',
{ charakterId })
if not ziel or not ziel[1] then return nil, 'Personaldatensatz nicht gefunden.' end
local darf, grund = DarfVerwalten(beamter, ziel[1])
if not darf then return nil, grund end
local neueRolle = Polizei.HoleRolle(tostring(daten.rolle or ''))
if not neueRolle then return nil, 'Unbekannte Rolle.' end
-- Schranke 2: nie auf oder über den eigenen Rang befördern
if neueRolle.stufe >= beamter.stufe then
return nil, 'Du kannst niemanden auf deinen Rang oder darüber setzen.'
end
local vermerk = type(daten.vermerk) == 'string' and daten.vermerk:sub(1, 255) or ''
if #vermerk < 3 then return nil, 'Bitte einen Vermerk zur Änderung angeben.' end
local alteStufe = Polizei.Stufe(ziel[1].rolle)
local richtung = neueRolle.stufe > alteStufe and 'Beförderung' or
neueRolle.stufe < alteStufe and 'Degradierung' or 'Rollenwechsel'
MySQL.update.await('UPDATE police_personal SET rolle = ? WHERE `character` = ?',
{ neueRolle.id, charakterId })
-- Läuft die Person gerade im Dienst, Rechte sofort neu laden
local zielQuelle = Dienst.QuelleVonCharakter(charakterId)
if zielQuelle then
local neu = Dienst.LadeBeamten(zielQuelle, charakterId)
if neu then
local aktuell = Dienst.Hole(zielQuelle)
aktuell.rolle = neu.rolle
aktuell.rolleName = neu.rolleName
aktuell.stufe = neu.stufe
aktuell.rechte = neu.rechte
end
Hinweise.Erstelle({ charakterId = charakterId }, {
kategorie = 'system', prioritaet = 'normal',
titel = richtung,
text = ('Dein Dienstgrad wurde auf %s gesetzt: %s'):format(neueRolle.name, vermerk),
})
end
Protokoll.Schreibe(beamter, 'personal', richtung,
{ typ = 'person', id = charakterId },
{ von = ziel[1].rolle, nach = neueRolle.id, dienstnummer = ziel[1].dienstnummer }, vermerk)
return { ok = true }
end)
Kern.RegistriereAktion('personal', 'abteilungAendern', 'personal.rolle', function(quelle, beamter, daten)
local charakterId = tonumber(daten.charakterId)
if not charakterId then return nil, 'Ungültige Kennung.' end
local ziel = MySQL.query.await(
'SELECT `character`, rolle FROM police_personal WHERE `character` = ? AND aktiv = 1',
{ charakterId })
if not ziel or not ziel[1] then return nil, 'Personaldatensatz nicht gefunden.' end
local darf, grund = DarfVerwalten(beamter, ziel[1])
if not darf then return nil, grund end
local abteilung = nil
for _, eintrag in ipairs(Polizei.Config.Abteilungen) do
if eintrag.id == daten.abteilung then abteilung = eintrag break end
end
if not abteilung then return nil, 'Unbekannte Abteilung.' end
MySQL.update.await('UPDATE police_personal SET abteilung = ? WHERE `character` = ?',
{ abteilung.id, charakterId })
Protokoll.Schreibe(beamter, 'personal', 'Abteilung geändert',
{ typ = 'person', id = charakterId }, { abteilung = abteilung.id })
return { ok = true }
end)
-- =====================================================================
-- EINZELRECHTE
--
-- Schranke 3: Niemand kann ein Recht vergeben, das er selbst nicht hat.
-- =====================================================================
Kern.RegistriereAktion('personal', 'rechtSetzen', 'personal.rechte', function(quelle, beamter, daten)
local charakterId = tonumber(daten.charakterId)
local rechtId = tostring(daten.recht or '')
local modus = tostring(daten.modus or '') -- zusaetzlich | entzogen | rolle
if not charakterId or rechtId == '' then return nil, 'Unvollständige Angaben.' end
if modus ~= 'zusaetzlich' and modus ~= 'entzogen' and modus ~= 'rolle' then
return nil, 'Unbekannter Modus.'
end
-- Existiert das Recht überhaupt?
local bekannt = false
for _, id in ipairs(Polizei.AlleRechte()) do
if id == rechtId then bekannt = true break end
end
if not bekannt then return nil, 'Unbekanntes Recht.' end
local ziel = MySQL.query.await(
'SELECT `character`, rolle, rechte_extra, rechte_entzug, dienstnummer FROM police_personal WHERE `character` = ? AND aktiv = 1',
{ charakterId })
if not ziel or not ziel[1] then return nil, 'Personaldatensatz nicht gefunden.' end
local darf, grund = DarfVerwalten(beamter, ziel[1])
if not darf then return nil, grund end
-- Schranke 3: nur eigene Rechte weitergeben
if modus == 'zusaetzlich' and not Rechte.Hat(beamter, rechtId) then
return nil, 'Du kannst nur Rechte vergeben, die du selbst besitzt.'
end
local extra = ZuMenge(ziel[1].rechte_extra)
local entzug = ZuMenge(ziel[1].rechte_entzug)
-- Erst beide Listen bereinigen, dann neu setzen
extra[rechtId] = nil
entzug[rechtId] = nil
if modus == 'zusaetzlich' then extra[rechtId] = true
elseif modus == 'entzogen' then entzug[rechtId] = true end
-- modus == 'rolle' lässt beide leer: es gilt wieder die Rolle
MySQL.update.await(
'UPDATE police_personal SET rechte_extra = ?, rechte_entzug = ? WHERE `character` = ?',
{ json.encode(ZuListe(extra)), json.encode(ZuListe(entzug)), charakterId })
-- Im Dienst? Dann sofort wirksam machen
local zielQuelle = Dienst.QuelleVonCharakter(charakterId)
if zielQuelle then
local neu = Dienst.LadeBeamten(zielQuelle, charakterId)
if neu then Dienst.Hole(zielQuelle).rechte = neu.rechte end
end
Protokoll.Schreibe(beamter, 'personal', 'Einzelrecht geändert',
{ typ = 'person', id = charakterId },
{ recht = rechtId, modus = modus, dienstnummer = ziel[1].dienstnummer }, daten.vermerk)
return { ok = true }
end)
-- =====================================================================
-- NOTIZ UND ENTLASSUNG
-- =====================================================================
Kern.RegistriereAktion('personal', 'notiz', 'personal.rolle', function(quelle, beamter, daten)
local charakterId = tonumber(daten.charakterId)
if not charakterId then return nil, 'Ungültige Kennung.' end
local ziel = MySQL.query.await(
'SELECT `character`, rolle FROM police_personal WHERE `character` = ?', { charakterId })
if not ziel or not ziel[1] then return nil, 'Personaldatensatz nicht gefunden.' end
local darf, grund = DarfVerwalten(beamter, ziel[1])
if not darf then return nil, grund end
MySQL.update.await('UPDATE police_personal SET notiz = ? WHERE `character` = ?',
{ type(daten.notiz) == 'string' and daten.notiz:sub(1, 255) or nil, charakterId })
Protokoll.Schreibe(beamter, 'personal', 'Personalnotiz geändert',
{ typ = 'person', id = charakterId })
return { ok = true }
end)
Kern.RegistriereAktion('personal', 'entlassen', 'personal.einstellen', function(quelle, beamter, daten)
local charakterId = tonumber(daten.charakterId)
if not charakterId then return nil, 'Ungültige Kennung.' end
local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or ''
if #grund < 5 then return nil, 'Eine Entlassung erfordert eine Begründung.' end
local ziel = MySQL.query.await([[
SELECT p.`character`, p.rolle, p.dienstnummer, c.firstname, c.lastname
FROM police_personal p
JOIN aether_characters c ON c.id = p.`character`
WHERE p.`character` = ? AND p.aktiv = 1
]], { charakterId })
if not ziel or not ziel[1] then return nil, 'Personaldatensatz nicht gefunden.' end
local darf, sperre = DarfVerwalten(beamter, ziel[1])
if not darf then return nil, sperre end
-- Datensatz bleibt bestehen, nur inaktiv gesetzt
MySQL.update.await('UPDATE police_personal SET aktiv = 0 WHERE `character` = ?', { charakterId })
-- Läuft die Person im Dienst, wird sie abgemeldet
local zielQuelle = Dienst.QuelleVonCharakter(charakterId)
if zielQuelle then
Dienst.Beenden(zielQuelle)
TriggerClientEvent('aether:police:schliessen', zielQuelle)
end
-- Zugewiesene Dienstfahrzeuge freigeben
MySQL.update('UPDATE police_fahrzeug SET zugewiesen_an = NULL WHERE zugewiesen_an = ?',
{ charakterId })
Protokoll.Schreibe(beamter, 'personal', 'Beamten entlassen',
{ typ = 'person', id = charakterId },
{ dienstnummer = ziel[1].dienstnummer, rolle = ziel[1].rolle }, grund)
Hinweise.Erstelle(nil, {
kategorie = 'system', prioritaet = 'normal',
titel = 'Personalveränderung',
text = ('%s %s (%s) wurde aus dem Dienst entlassen.')
:format(ziel[1].firstname, ziel[1].lastname, ziel[1].dienstnummer),
})
Kern.Loese('personal.entlassen', charakterId, beamter)
return { ok = true }
end)