aether-framework/resources/[aether]/aether-police/server/module/anzeige.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

444 lines
19 KiB
Lua

-- =====================================================================
-- aether-police / Modul: Strafenkatalog & Anzeigen
--
-- Der Katalog ist ein NACHSCHLAGEWERK. Er berechnet einen Vorschlag —
-- entschieden wird von einem Beamten. Deshalb speichert jede Anzeige
-- beides getrennt:
-- haft_empfohlen / geld_empfohlen = Vorschlag des Katalogs
-- haft_final / geld_final = Entscheidung des Beamten
-- Weicht der Beamte ab, ist eine Begründung Pflicht.
-- =====================================================================
Kern.RegistriereModul({
id = 'anzeige', name = 'Anzeigen', icon = '⚖',
reihenfolge = 4, recht = 'anzeige.erstellen',
beschreibung = 'Strafenkatalog und Anzeigenerfassung',
})
-- ---------------------------------------------------------------------
-- Grundbestand des Katalogs
-- Wird nur angelegt, wenn die Tabelle leer ist. Danach pflegt ihn die
-- Administration — hier wird nichts mehr überschrieben.
-- ---------------------------------------------------------------------
local GRUNDKATALOG = {
-- kennung, kategorie, bezeichnung, paragraph, haft (Min), geld, hinweis
{ 'v1', 'Verkehr', 'Überhöhte Geschwindigkeit', '§ 3 StVO', 0, 450,
'Bei erheblicher Überschreitung Fahrverbot prüfen.' },
{ 'v2', 'Verkehr', 'Fahren ohne Fahrerlaubnis', '§ 21 StVG', 10, 1200,
'Führerscheinstatus im MDT prüfen.' },
{ 'v3', 'Verkehr', 'Unerlaubtes Entfernen vom Unfallort', '§ 142 StGB', 30, 5000, nil },
{ 'v4', 'Verkehr', 'Trunkenheit im Verkehr', '§ 316 StGB', 20, 3500,
'Bei Gefährdung § 315c prüfen.' },
{ 'v5', 'Verkehr', 'Verbotenes Kraftfahrzeugrennen', '§ 315d StGB', 45, 9000, nil },
{ 'e1', 'Eigentum', 'Diebstahl (geringwertig)', '§ 242 StGB', 15, 800, nil },
{ 'e2', 'Eigentum', 'Einbruchdiebstahl', '§ 243 StGB', 45, 6000, nil },
{ 'e3', 'Eigentum', 'Sachbeschädigung', '§ 303 StGB', 10, 1500, nil },
{ 'e4', 'Eigentum', 'Hehlerei', '§ 259 StGB', 25, 4000, nil },
{ 'g1', 'Gewalt', 'Körperverletzung', '§ 223 StGB', 45, 8000, nil },
{ 'g2', 'Gewalt', 'Gefährliche Körperverletzung', '§ 224 StGB', 90, 15000, nil },
{ 'g3', 'Gewalt', 'Raub', '§ 249 StGB', 120, 25000, nil },
{ 'g4', 'Gewalt', 'Widerstand gegen Vollstreckungsbeamte', '§ 113 StGB', 30, 4000, nil },
{ 'g5', 'Gewalt', 'Bedrohung', '§ 241 StGB', 20, 2500, nil },
{ 'g6', 'Gewalt', 'Freiheitsberaubung', '§ 239 StGB', 60, 9000, nil },
{ 'w1', 'Waffen', 'Unerlaubter Waffenbesitz', '§ 52 WaffG', 60, 10000,
'Waffenschein im MDT prüfen — ggf. Einziehung dokumentieren.' },
{ 'w2', 'Waffen', 'Führen einer Waffe ohne Erlaubnis', '§ 52 WaffG', 30, 5000, nil },
{ 'b1', 'Betäubungsmittel', 'Besitz von Betäubungsmitteln', '§ 29 BtMG', 25, 3000, nil },
{ 'b2', 'Betäubungsmittel', 'Handel mit Betäubungsmitteln', '§ 29a BtMG', 110, 20000, nil },
{ 's1', 'Sonstige', 'Hausfriedensbruch', '§ 123 StGB', 15, 1500, nil },
{ 's2', 'Sonstige', 'Beleidigung', '§ 185 StGB', 0, 800, nil },
{ 's3', 'Sonstige', 'Falsche Verdächtigung', '§ 164 StGB', 30, 4500, nil },
{ 's4', 'Sonstige', 'Urkundenfälschung', '§ 267 StGB', 40, 6000, nil },
}
CreateThread(function()
Wait(3000)
local anzahl = MySQL.scalar.await('SELECT COUNT(*) FROM police_katalog') or 0
if anzahl > 0 then return end
for reihenfolge, eintrag in ipairs(GRUNDKATALOG) do
MySQL.insert.await([[
INSERT INTO police_katalog
(kennung, kategorie, bezeichnung, paragraph, haft, geld, hinweis, sortierung)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
]], { eintrag[1], eintrag[2], eintrag[3], eintrag[4],
eintrag[5], eintrag[6], eintrag[7], reihenfolge })
end
Aether.Info(('Strafenkatalog mit %d Einträgen angelegt.'):format(#GRUNDKATALOG))
end)
-- ---------------------------------------------------------------------
-- Hilfsfunktionen
-- ---------------------------------------------------------------------
--- Lädt den aktiven Katalog, nach Kategorien gruppiert
--- @return table
local function LadeKatalog()
local eintraege = MySQL.query.await([[
SELECT id, kennung, kategorie, bezeichnung, paragraph, haft, geld, hinweis
FROM police_katalog WHERE aktiv = 1
ORDER BY sortierung ASC, id ASC
]]) or {}
local gruppen, reihenfolge = {}, {}
for _, eintrag in ipairs(eintraege) do
if not gruppen[eintrag.kategorie] then
gruppen[eintrag.kategorie] = {}
reihenfolge[#reihenfolge + 1] = eintrag.kategorie
end
table.insert(gruppen[eintrag.kategorie], eintrag)
end
local liste = {}
for _, kategorie in ipairs(reihenfolge) do
liste[#liste + 1] = { kategorie = kategorie, delikte = gruppen[kategorie] }
end
return liste
end
--- Rechnet den Vorschlag für eine Auswahl von Delikten aus.
--- Der Katalog ist die einzige Quelle — der Client schickt nur Kennungen.
--- @param kennungen table Liste von Katalog-Kennungen
--- @return table|nil { delikte, haft, geld }, string|nil Fehler
local function BerechneVorschlag(kennungen)
if type(kennungen) ~= 'table' or #kennungen == 0 then
return nil, 'Bitte mindestens ein Delikt auswählen.'
end
if #kennungen > 25 then
return nil, 'Zu viele Delikte auf einmal.'
end
local delikte, haft, geld = {}, 0, 0
for _, kennung in ipairs(kennungen) do
local eintrag = MySQL.query.await(
'SELECT kennung, bezeichnung, paragraph, haft, geld FROM police_katalog WHERE kennung = ? AND aktiv = 1',
{ tostring(kennung) })
if not eintrag or not eintrag[1] then
return nil, ('Unbekanntes Delikt: %s'):format(tostring(kennung))
end
local delikt = eintrag[1]
delikte[#delikte + 1] = {
kennung = delikt.kennung, name = delikt.bezeichnung,
paragraph = delikt.paragraph, haft = delikt.haft, geld = delikt.geld,
}
haft = haft + (delikt.haft or 0)
geld = geld + (delikt.geld or 0)
end
return { delikte = delikte, haft = haft, geld = geld }
end
-- =====================================================================
-- KATALOG ABRUFEN
-- =====================================================================
Kern.RegistriereAktion('anzeige', 'katalog', nil, function(quelle, beamter)
return {
katalog = LadeKatalog(),
rechte = {
bestaetigen = Rechte.Hat(beamter, 'anzeige.bestaetigen'),
abweichen = Rechte.Hat(beamter, 'anzeige.abweichen'),
bearbeiten = Rechte.Hat(beamter, 'katalog.bearbeiten'),
},
}
end)
--- Berechnet den Vorschlag zu einer Auswahl — reine Vorschau, speichert nichts
Kern.RegistriereAktion('anzeige', 'vorschlag', nil, function(quelle, beamter, daten)
local vorschlag, fehler = BerechneVorschlag(daten.delikte)
if not vorschlag then return nil, fehler end
-- Zusätzliche Hinweise der gewählten Delikte mitliefern
local hinweise = {}
for _, kennung in ipairs(daten.delikte) do
local hinweis = MySQL.scalar.await(
'SELECT hinweis FROM police_katalog WHERE kennung = ? AND aktiv = 1', { kennung })
if hinweis and hinweis ~= '' then hinweise[#hinweise + 1] = hinweis end
end
return {
delikte = vorschlag.delikte,
haft = vorschlag.haft,
geld = vorschlag.geld,
hinweise = hinweise,
}
end)
-- =====================================================================
-- ANZEIGE ERFASSEN
--
-- Erzeugt einen ENTWURF. Nichts wird eingezogen, niemand kommt in Haft.
-- =====================================================================
Kern.RegistriereAktion('anzeige', 'erstellen', 'anzeige.erstellen', function(quelle, beamter, daten)
local beschuldigter = tonumber(daten.beschuldigter)
if not beschuldigter then return nil, 'Kein Beschuldigter ausgewählt.' end
local person = MySQL.scalar.await(
'SELECT id FROM aether_characters WHERE id = ? AND deleted = 0', { beschuldigter })
if not person then return nil, 'Diese Person existiert nicht.' end
local vorschlag, fehler = BerechneVorschlag(daten.delikte)
if not vorschlag then return nil, fehler end
-- Werte des Beamten übernehmen; fehlen sie, gilt der Vorschlag
local haftFinal = daten.haft ~= nil and math.max(0, math.min(1440, math.floor(tonumber(daten.haft) or 0))) or vorschlag.haft
local geldFinal = daten.geld ~= nil and math.max(0, math.min(10000000, math.floor(tonumber(daten.geld) or 0))) or vorschlag.geld
local weichtAb = (haftFinal ~= vorschlag.haft) or (geldFinal ~= vorschlag.geld)
local begruendung = type(daten.begruendung) == 'string'
and daten.begruendung:gsub('^%s+', ''):gsub('%s+$', '') or ''
-- Abweichen ist ein eigenes Recht und verlangt eine Begründung
if weichtAb then
if not Rechte.Hat(beamter, 'anzeige.abweichen') then
return nil, 'Dir fehlt die Berechtigung, vom Katalogvorschlag abzuweichen.'
end
if #begruendung < 5 then
return nil, 'Bei Abweichung vom Vorschlag ist eine Begründung erforderlich.'
end
end
local anzeigeId = MySQL.insert.await([[
INSERT INTO police_anzeige
(akte, beschuldigter, delikte, haft_empfohlen, geld_empfohlen,
haft_final, geld_final, begruendung, status, erstellt_von)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'entwurf', ?)
]], {
tonumber(daten.akte), beschuldigter, json.encode(vorschlag.delikte),
vorschlag.haft, vorschlag.geld, haftFinal, geldFinal,
begruendung ~= '' and begruendung or nil, beamter.charakterId,
})
Protokoll.Schreibe(beamter, 'anzeige', 'Anzeige als Entwurf erfasst',
{ typ = 'person', id = beschuldigter },
{ anzeige = anzeigeId, haft = haftFinal, geld = geldFinal, abweichung = weichtAb },
begruendung ~= '' and begruendung or nil)
Kern.Loese('anzeige.erstellt', anzeigeId, beamter)
return { id = anzeigeId, entwurf = true }
end)
-- =====================================================================
-- BESTÄTIGEN
--
-- Erst hier wird die Entscheidung des Beamten ausgeführt: Das Bußgeld
-- wird eingezogen. Eine Haft entsteht dadurch NICHT — die Einweisung
-- in Gewahrsam ist ein eigener, bewusster Schritt.
-- =====================================================================
Kern.RegistriereAktion('anzeige', 'bestaetigen', 'anzeige.bestaetigen', function(quelle, beamter, daten)
local anzeigeId = tonumber(daten.id)
if not anzeigeId then return nil, 'Ungültige Kennung.' end
local anzeige = MySQL.query.await('SELECT * FROM police_anzeige WHERE id = ?', { anzeigeId })
if not anzeige or not anzeige[1] then return nil, 'Anzeige nicht gefunden.' end
local eintrag = anzeige[1]
if eintrag.status ~= 'entwurf' then
return nil, 'Diese Anzeige wurde bereits abschließend bearbeitet.'
end
-- Vier-Augen-Prinzip: Wer erfasst hat, darf nur mit dem Recht
-- „bestaetigen" auch selbst bestätigen — das ist hier gegeben,
-- wird aber protokolliert.
local selbstbestaetigung = eintrag.erstellt_von == beamter.charakterId
-- Bußgeld einziehen (erst Bargeld, dann Konto)
local Core = exports['aether-core']
local eingezogen = 0
if eintrag.geld_final > 0 then
local zielQuelle = Core:HoleQuelleNachCharakter(eintrag.beschuldigter)
local bezahlt = false
if zielQuelle then
bezahlt = Core:EntferneGeld(zielQuelle, 'bargeld', eintrag.geld_final, 'Bußgeld')
if not bezahlt then
bezahlt = Core:EntferneGeld(zielQuelle, 'konto', eintrag.geld_final, 'Bußgeld')
end
else
-- Offline: direkt vom Konto, auch ins Minus (Forderung bleibt bestehen)
MySQL.update.await('UPDATE aether_characters SET bank = bank - ? WHERE id = ?',
{ eintrag.geld_final, eintrag.beschuldigter })
bezahlt = true
end
if bezahlt then
eingezogen = eintrag.geld_final
MySQL.update('UPDATE aether_treasury SET betrag = betrag + ? WHERE id = 1',
{ eintrag.geld_final })
end
end
MySQL.update.await([[
UPDATE police_anzeige
SET status = 'bestaetigt', bestaetigt_von = ?, bestaetigt_am = NOW()
WHERE id = ?
]], { beamter.charakterId, anzeigeId })
Protokoll.Schreibe(beamter, 'anzeige', 'Anzeige bestätigt',
{ typ = 'person', id = eintrag.beschuldigter },
{ anzeige = anzeigeId, eingezogen = eingezogen,
selbstbestaetigung = selbstbestaetigung })
-- Erinnerung, falls eine Haft festgelegt wurde — aber NICHT ausgeführt
if eintrag.haft_final > 0 then
Hinweise.Erstelle({ charakterId = beamter.charakterId }, {
kategorie = 'gewahrsam', prioritaet = 'normal',
titel = 'Haftzeit festgelegt — Einweisung offen',
text = ('Die Anzeige sieht %d Minuten vor. Die Einweisung in Gewahrsam ist ein eigener Schritt.')
:format(eintrag.haft_final),
zielTyp = 'person', zielId = eintrag.beschuldigter,
})
end
Kern.Loese('anzeige.bestaetigt', anzeigeId, beamter)
return {
ok = true,
eingezogen = eingezogen,
haft = eintrag.haft_final,
beschuldigter = eintrag.beschuldigter,
}
end)
--- Entwurf verwerfen
Kern.RegistriereAktion('anzeige', 'verwerfen', 'anzeige.erstellen', function(quelle, beamter, daten)
local anzeigeId = tonumber(daten.id)
if not anzeigeId then return nil, 'Ungültige Kennung.' end
local anzeige = MySQL.query.await(
'SELECT erstellt_von, status, beschuldigter FROM police_anzeige WHERE id = ?', { anzeigeId })
if not anzeige or not anzeige[1] then return nil, 'Anzeige nicht gefunden.' end
if anzeige[1].status ~= 'entwurf' then
return nil, 'Nur Entwürfe können verworfen werden.'
end
-- Fremde Entwürfe darf nur verwerfen, wer bestätigen darf
if anzeige[1].erstellt_von ~= beamter.charakterId
and not Rechte.Hat(beamter, 'anzeige.bestaetigen') then
return nil, 'Du kannst nur eigene Entwürfe verwerfen.'
end
local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or nil
MySQL.update.await("UPDATE police_anzeige SET status = 'verworfen' WHERE id = ?", { anzeigeId })
Protokoll.Schreibe(beamter, 'anzeige', 'Anzeige verworfen',
{ typ = 'person', id = anzeige[1].beschuldigter }, { anzeige = anzeigeId }, grund)
return { ok = true }
end)
-- =====================================================================
-- ÜBERSICHT UND EINZELANSICHT
-- =====================================================================
Kern.RegistriereAktion('anzeige', 'liste', nil, function(quelle, beamter, daten)
local bedingungen, werte = { '1=1' }, {}
if daten.status and daten.status ~= '' then
bedingungen[#bedingungen + 1] = 'a.status = ?'
werte[#werte + 1] = daten.status
end
if daten.nurEigene then
bedingungen[#bedingungen + 1] = 'a.erstellt_von = ?'
werte[#werte + 1] = beamter.charakterId
end
local anzeigen = MySQL.query.await(([[
SELECT a.id, a.beschuldigter, a.delikte, a.haft_empfohlen, a.geld_empfohlen,
a.haft_final, a.geld_final, a.begruendung, a.status,
a.erstellt_am, a.bestaetigt_am,
b.firstname AS b_vorname, b.lastname AS b_nachname,
e.firstname AS e_vorname, e.lastname AS e_nachname
FROM police_anzeige a
LEFT JOIN aether_characters b ON b.id = a.beschuldigter
LEFT JOIN aether_characters e ON e.id = a.erstellt_von
WHERE %s
ORDER BY
CASE WHEN a.status = 'entwurf' THEN 0 ELSE 1 END,
a.id DESC
LIMIT 60
]]):format(table.concat(bedingungen, ' AND ')), werte) or {}
for _, anzeige in ipairs(anzeigen) do
local erfolg, liste = pcall(json.decode, anzeige.delikte)
anzeige.delikte = (erfolg and type(liste) == 'table') and liste or {}
-- Weicht die Entscheidung vom Vorschlag ab?
anzeige.abweichung = (anzeige.haft_final ~= anzeige.haft_empfohlen)
or (anzeige.geld_final ~= anzeige.geld_empfohlen)
end
return {
anzeigen = anzeigen,
rechte = {
bestaetigen = Rechte.Hat(beamter, 'anzeige.bestaetigen'),
abweichen = Rechte.Hat(beamter, 'anzeige.abweichen'),
},
}
end)
-- =====================================================================
-- KATALOGPFLEGE (Administration)
-- =====================================================================
Kern.RegistriereAktion('anzeige', 'katalogSpeichern', 'katalog.bearbeiten', function(quelle, beamter, daten)
local kennung = type(daten.kennung) == 'string' and daten.kennung:gsub('%s', '') or ''
local bezeichnung = type(daten.bezeichnung) == 'string'
and daten.bezeichnung:gsub('^%s+', ''):gsub('%s+$', '') or ''
if #kennung < 1 or #kennung > 24 then return nil, 'Ungültige Kennung.' end
if #bezeichnung < 3 then return nil, 'Bitte eine Bezeichnung angeben.' end
local haft = math.max(0, math.min(1440, math.floor(tonumber(daten.haft) or 0)))
local geld = math.max(0, math.min(10000000, math.floor(tonumber(daten.geld) or 0)))
MySQL.query.await([[
INSERT INTO police_katalog
(kennung, kategorie, bezeichnung, paragraph, haft, geld, hinweis, aktiv)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
ON DUPLICATE KEY UPDATE
kategorie = VALUES(kategorie), bezeichnung = VALUES(bezeichnung),
paragraph = VALUES(paragraph), haft = VALUES(haft), geld = VALUES(geld),
hinweis = VALUES(hinweis), aktiv = 1
]], {
kennung,
type(daten.kategorie) == 'string' and daten.kategorie:sub(1, 48) or 'Sonstige',
bezeichnung,
type(daten.paragraph) == 'string' and daten.paragraph:sub(1, 48) or nil,
haft, geld,
type(daten.hinweis) == 'string' and daten.hinweis:sub(1, 512) or nil,
})
Protokoll.Schreibe(beamter, 'anzeige', 'Katalogeintrag gespeichert',
{ typ = 'katalog', id = kennung }, { haft = haft, geld = geld })
return { ok = true }
end)
Kern.RegistriereAktion('anzeige', 'katalogDeaktivieren', 'katalog.bearbeiten', function(quelle, beamter, daten)
local kennung = tostring(daten.kennung or '')
if kennung == '' then return nil, 'Ungültige Kennung.' end
-- Nicht löschen, damit alte Anzeigen nachvollziehbar bleiben
MySQL.update.await('UPDATE police_katalog SET aktiv = 0 WHERE kennung = ?', { kennung })
Protokoll.Schreibe(beamter, 'anzeige', 'Katalogeintrag deaktiviert',
{ typ = 'katalog', id = kennung })
return { ok = true }
end)