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

496 lines
20 KiB
Lua

-- =====================================================================
-- aether-police / Modul: Einsatzverwaltung & Dispatch
--
-- Einsätze entstehen entweder durch einen Beamten oder durch einen
-- eingehenden Notruf. Sie werden NIE automatisch zugewiesen,
-- bearbeitet oder geschlossen — das entscheidet die Leitstelle
-- beziehungsweise der jeweilige Beamte.
-- =====================================================================
Kern.RegistriereModul({
id = 'einsatz', name = 'Einsätze', icon = '🚨',
reihenfolge = 3, recht = 'einsatz.erstellen',
beschreibung = 'Dispatch und Einsatzverwaltung',
})
-- ---------------------------------------------------------------------
-- Hilfsfunktionen
-- ---------------------------------------------------------------------
--- Prüft, ob eine Einsatzkategorie bekannt ist
local function KategorieGueltig(kategorie)
for _, eintrag in ipairs(Polizei.Config.Einsatzkategorien) do
if eintrag.id == kategorie then return true end
end
return false
end
--- Lädt einen Einsatz samt beteiligten Beamten und Notizen
--- @param einsatzId number
--- @return table|nil
local function LadeEinsatz(einsatzId)
local einsatz = MySQL.query.await([[
SELECT e.*, c.firstname AS ersteller_vorname, c.lastname AS ersteller_nachname
FROM police_einsatz e
LEFT JOIN aether_characters c ON c.id = e.erstellt_von
WHERE e.id = ?
]], { einsatzId })
if not einsatz or not einsatz[1] then return nil end
local eintrag = einsatz[1]
eintrag.beamte = MySQL.query.await([[
SELECT b.character, b.beigetreten, b.verlassen,
c.firstname, c.lastname, p.dienstnummer
FROM police_einsatz_beamter b
LEFT JOIN aether_characters c ON c.id = b.character
LEFT JOIN police_personal p ON p.`character` = b.character
WHERE b.einsatz = ?
ORDER BY b.id ASC
]], { einsatzId }) or {}
eintrag.notizen = MySQL.query.await([[
SELECT n.id, n.text, n.am, c.firstname, c.lastname, p.dienstnummer
FROM police_einsatz_notiz n
LEFT JOIN aether_characters c ON c.id = n.von
LEFT JOIN police_personal p ON p.`character` = n.von
WHERE n.einsatz = ?
ORDER BY n.id ASC
]], { einsatzId }) or {}
return eintrag
end
--- Informiert alle Beamten im Dienst über eine Änderung an den Einsätzen
local function VerteileEinsaetze()
for quelle in pairs(Dienst.AlleImDienst()) do
TriggerClientEvent('aether:police:einsatzAenderung', quelle)
end
end
--- Zählt, wie viele Beamte einem Einsatz aktuell zugeordnet sind
local function AnzahlBeamte(einsatzId)
return MySQL.scalar.await(
'SELECT COUNT(*) FROM police_einsatz_beamter WHERE einsatz = ? AND verlassen IS NULL',
{ einsatzId }) or 0
end
-- =====================================================================
-- ÜBERSICHT
-- =====================================================================
Kern.RegistriereAktion('einsatz', 'liste', nil, function(quelle, beamter, daten)
local nurOffene = daten.nurOffene ~= false
local einsaetze = MySQL.query.await(([[
SELECT e.id, e.nummer, e.titel, e.kategorie, e.prioritaet, e.ort,
e.pos_x, e.pos_y, e.pos_z, e.status, e.quelle, e.erstellt_am,
e.beschreibung,
(SELECT COUNT(*) FROM police_einsatz_beamter b
WHERE b.einsatz = e.id AND b.verlassen IS NULL) AS einheiten,
(SELECT COUNT(*) FROM police_einsatz_beamter b2
WHERE b2.einsatz = e.id AND b2.character = ? AND b2.verlassen IS NULL) AS ichDabei
FROM police_einsatz e
%s
ORDER BY
CASE WHEN e.status = 'offen' THEN 0 ELSE 1 END,
e.prioritaet ASC, e.id DESC
LIMIT 60
]]):format(nurOffene and "WHERE e.status <> 'abgeschlossen'" or ''),
{ beamter.charakterId }) or {}
return {
einsaetze = einsaetze,
kategorien = Polizei.Config.Einsatzkategorien,
istLeitstelle = Dienst.Leitstelle() == quelle,
beamteImDienst = Dienst.BaueListe(),
rechte = {
zuweisen = Rechte.Hat(beamter, 'einsatz.zuweisen'),
schliessen = Rechte.Hat(beamter, 'einsatz.schliessen'),
bericht = Rechte.Hat(beamter, 'bericht.erstellen'),
},
}
end)
Kern.RegistriereAktion('einsatz', 'oeffnen', nil, function(quelle, beamter, daten)
local einsatzId = tonumber(daten.id)
if not einsatzId then return nil, 'Ungültige Kennung.' end
local einsatz = LadeEinsatz(einsatzId)
if not einsatz then return nil, 'Einsatz nicht gefunden.' end
-- Bin ich selbst beteiligt?
einsatz.ichDabei = false
for _, eintrag in ipairs(einsatz.beamte) do
if eintrag.character == beamter.charakterId and not eintrag.verlassen then
einsatz.ichDabei = true
break
end
end
return {
einsatz = einsatz,
istLeitstelle = Dienst.Leitstelle() == quelle,
beamteImDienst = Dienst.BaueListe(),
rechte = {
zuweisen = Rechte.Hat(beamter, 'einsatz.zuweisen'),
schliessen = Rechte.Hat(beamter, 'einsatz.schliessen'),
bericht = Rechte.Hat(beamter, 'bericht.erstellen'),
},
}
end)
-- =====================================================================
-- ANLEGEN
-- =====================================================================
Kern.RegistriereAktion('einsatz', 'erstellen', 'einsatz.erstellen', function(quelle, beamter, daten)
local titel = type(daten.titel) == 'string' and daten.titel:gsub('^%s+', ''):gsub('%s+$', '') or ''
if #titel < 3 or #titel > 160 then
return nil, 'Der Titel muss 3 bis 160 Zeichen lang sein.'
end
if not KategorieGueltig(daten.kategorie) then
return nil, 'Unbekannte Kategorie.'
end
local prioritaet = math.max(1, math.min(4, tonumber(daten.prioritaet) or 3))
local nummer = Kern.ErzeugeKennung('EIN', 'police_einsatz', 'nummer')
-- Position: entweder mitgeschickt oder die des erstellenden Beamten
local position = daten.position
if daten.eigenePosition then
local koordinaten = GetEntityCoords(GetPlayerPed(quelle))
position = { x = koordinaten.x, y = koordinaten.y, z = koordinaten.z }
end
local einsatzId = MySQL.insert.await([[
INSERT INTO police_einsatz
(nummer, titel, kategorie, prioritaet, ort, pos_x, pos_y, pos_z,
beschreibung, status, quelle, erstellt_von)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'offen', 'manuell', ?)
]], {
nummer, titel, daten.kategorie, prioritaet,
type(daten.ort) == 'string' and daten.ort:sub(1, 160) or nil,
position and position.x, position and position.y, position and position.z,
type(daten.beschreibung) == 'string' and daten.beschreibung or nil,
beamter.charakterId,
})
Protokoll.Schreibe(beamter, 'einsatz', 'Einsatz angelegt',
{ typ = 'einsatz', id = einsatzId }, { nummer = nummer, kategorie = daten.kategorie })
-- Alle Beamten im Dienst informieren — zugewiesen wird nichts
Hinweise.Erstelle(nil, {
kategorie = 'einsatz',
prioritaet = prioritaet == 1 and 'kritisch' or prioritaet == 2 and 'hoch' or 'normal',
titel = 'Neuer Einsatz: ' .. titel,
text = (daten.ort and ('Ort: ' .. daten.ort .. ' · ') or '') .. 'Noch keine Einheit zugeordnet.',
zielTyp = 'einsatz', zielId = einsatzId,
})
Kern.Loese('einsatz.erstellt', einsatzId, beamter)
VerteileEinsaetze()
return { id = einsatzId, nummer = nummer }
end)
-- =====================================================================
-- BEITRETEN / VERLASSEN — immer freiwillig
-- =====================================================================
Kern.RegistriereAktion('einsatz', 'beitreten', nil, function(quelle, beamter, daten)
local einsatzId = tonumber(daten.id)
if not einsatzId then return nil, 'Ungültige Kennung.' end
local einsatz = MySQL.query.await('SELECT status FROM police_einsatz WHERE id = ?', { einsatzId })
if not einsatz or not einsatz[1] then return nil, 'Einsatz nicht gefunden.' end
if einsatz[1].status == 'abgeschlossen' then
return nil, 'Dieser Einsatz ist bereits abgeschlossen.'
end
-- Bereits dabei? Dann nichts tun
local vorhanden = MySQL.scalar.await([[
SELECT id FROM police_einsatz_beamter
WHERE einsatz = ? AND `character` = ? AND verlassen IS NULL
]], { einsatzId, beamter.charakterId })
if vorhanden then return { ok = true } end
MySQL.insert.await('INSERT INTO police_einsatz_beamter (einsatz, `character`) VALUES (?, ?)',
{ einsatzId, beamter.charakterId })
-- Sobald jemand dabei ist, gilt der Einsatz als zugewiesen
MySQL.update("UPDATE police_einsatz SET status = 'zugewiesen' WHERE id = ? AND status = 'offen'",
{ einsatzId })
Protokoll.Schreibe(beamter, 'einsatz', 'Einsatz beigetreten', { typ = 'einsatz', id = einsatzId })
Kern.Loese('einsatz.beigetreten', einsatzId, beamter)
VerteileEinsaetze()
return { ok = true }
end)
Kern.RegistriereAktion('einsatz', 'verlassen', nil, function(quelle, beamter, daten)
local einsatzId = tonumber(daten.id)
if not einsatzId then return nil, 'Ungültige Kennung.' end
MySQL.update.await([[
UPDATE police_einsatz_beamter SET verlassen = NOW()
WHERE einsatz = ? AND `character` = ? AND verlassen IS NULL
]], { einsatzId, beamter.charakterId })
-- Verlässt der letzte Beamte den Einsatz, geht er zurück auf „offen"
if AnzahlBeamte(einsatzId) == 0 then
MySQL.update("UPDATE police_einsatz SET status = 'offen' WHERE id = ? AND status <> 'abgeschlossen'",
{ einsatzId })
Hinweise.Erstelle(nil, {
kategorie = 'einsatz', prioritaet = 'normal',
titel = 'Einsatz ohne Einheit',
text = 'Der Einsatz hat keine zugeordnete Einheit mehr.',
zielTyp = 'einsatz', zielId = einsatzId,
})
end
Protokoll.Schreibe(beamter, 'einsatz', 'Einsatz verlassen', { typ = 'einsatz', id = einsatzId })
VerteileEinsaetze()
return { ok = true }
end)
-- =====================================================================
-- ZUWEISUNG DURCH DIE LEITSTELLE
--
-- Die Leitstelle spricht eine Zuweisung AUS — sie zwingt niemanden.
-- Der angesprochene Beamte entscheidet selbst, ob er beitritt.
-- =====================================================================
Kern.RegistriereAktion('einsatz', 'zuweisen', 'einsatz.zuweisen', function(quelle, beamter, daten)
local einsatzId = tonumber(daten.id)
local zielQuelle = tonumber(daten.quelle)
if not einsatzId or not zielQuelle then return nil, 'Unvollständige Angaben.' end
local ziel = Dienst.Hole(zielQuelle)
if not ziel then return nil, 'Diese Einheit ist nicht im Dienst.' end
local einsatz = MySQL.query.await(
'SELECT nummer, titel, ort, pos_x, pos_y, pos_z FROM police_einsatz WHERE id = ?',
{ einsatzId })
if not einsatz or not einsatz[1] then return nil, 'Einsatz nicht gefunden.' end
-- Aufforderung an die Einheit — kein automatischer Beitritt
TriggerClientEvent('aether:police:einsatzZuweisung', zielQuelle, {
id = einsatzId,
nummer = einsatz[1].nummer,
titel = einsatz[1].titel,
ort = einsatz[1].ort,
von = beamter.name .. ' (' .. beamter.dienstnummer .. ')',
position = einsatz[1].pos_x and {
x = einsatz[1].pos_x, y = einsatz[1].pos_y, z = einsatz[1].pos_z,
} or nil,
})
Hinweise.Erstelle({ charakterId = ziel.charakterId }, {
kategorie = 'einsatz', prioritaet = 'hoch',
titel = 'Zuweisung: ' .. einsatz[1].titel,
text = ('%s bittet dich, den Einsatz zu übernehmen.'):format(beamter.dienstnummer),
zielTyp = 'einsatz', zielId = einsatzId,
})
Protokoll.Schreibe(beamter, 'einsatz', 'Einheit zugewiesen',
{ typ = 'einsatz', id = einsatzId }, { einheit = ziel.dienstnummer })
return { ok = true }
end)
-- =====================================================================
-- STATUS UND NOTIZEN
-- =====================================================================
Kern.RegistriereAktion('einsatz', 'status', nil, function(quelle, beamter, daten)
local einsatzId = tonumber(daten.id)
local status = tostring(daten.status or '')
if not einsatzId then return nil, 'Ungültige Kennung.' end
local erlaubt = { offen = true, zugewiesen = true, vorort = true }
if not erlaubt[status] then return nil, 'Unbekannter Status.' end
MySQL.update.await("UPDATE police_einsatz SET status = ? WHERE id = ? AND status <> 'abgeschlossen'",
{ status, einsatzId })
Protokoll.Schreibe(beamter, 'einsatz', 'Status geändert',
{ typ = 'einsatz', id = einsatzId }, { status = status })
VerteileEinsaetze()
return { ok = true }
end)
Kern.RegistriereAktion('einsatz', 'notiz', nil, function(quelle, beamter, daten)
local einsatzId = tonumber(daten.id)
local text = type(daten.text) == 'string' and daten.text:gsub('^%s+', ''):gsub('%s+$', '') or ''
if not einsatzId then return nil, 'Ungültige Kennung.' end
if #text < 2 or #text > 1000 then return nil, 'Die Notiz muss 2 bis 1000 Zeichen lang sein.' end
MySQL.insert.await('INSERT INTO police_einsatz_notiz (einsatz, text, von) VALUES (?, ?, ?)',
{ einsatzId, text, beamter.charakterId })
Protokoll.Schreibe(beamter, 'einsatz', 'Notiz ergänzt', { typ = 'einsatz', id = einsatzId })
return { ok = true }
end)
-- =====================================================================
-- ABSCHLIESSEN
--
-- Beim Abschluss wird KEIN Bericht automatisch erzeugt. Das System
-- bietet lediglich an, einen vorbereiteten Entwurf anzulegen.
-- =====================================================================
Kern.RegistriereAktion('einsatz', 'abschliessen', 'einsatz.schliessen', function(quelle, beamter, daten)
local einsatzId = tonumber(daten.id)
if not einsatzId then return nil, 'Ungültige Kennung.' end
local einsatz = LadeEinsatz(einsatzId)
if not einsatz then return nil, 'Einsatz nicht gefunden.' end
if einsatz.status == 'abgeschlossen' then return nil, 'Der Einsatz ist bereits abgeschlossen.' end
MySQL.update.await([[
UPDATE police_einsatz
SET status = 'abgeschlossen', geschlossen_von = ?, geschlossen_am = NOW()
WHERE id = ?
]], { beamter.charakterId, einsatzId })
-- Alle noch offenen Beteiligungen beenden
MySQL.update('UPDATE police_einsatz_beamter SET verlassen = NOW() WHERE einsatz = ? AND verlassen IS NULL',
{ einsatzId })
Protokoll.Schreibe(beamter, 'einsatz', 'Einsatz abgeschlossen',
{ typ = 'einsatz', id = einsatzId }, nil, daten.grund)
local ergebnis = { ok = true }
-- Nur auf ausdrücklichen Wunsch einen Berichtsentwurf vorbereiten
if daten.berichtAnlegen and Rechte.Hat(beamter, 'bericht.erstellen') then
local nummer = Kern.ErzeugeKennung('BER', 'police_bericht', 'nummer')
-- Beteiligte aus dem Einsatz übernehmen
local beteiligte = {}
for _, eintrag in ipairs(einsatz.beamte) do
beteiligte[#beteiligte + 1] = {
dienstnummer = eintrag.dienstnummer,
name = (eintrag.firstname or '') .. ' ' .. (eintrag.lastname or ''),
}
end
-- Notizen als Ausgangstext, damit nichts abgetippt werden muss
local zeilen = {}
for _, notiz in ipairs(einsatz.notizen) do
zeilen[#zeilen + 1] = ('[%s] %s'):format(
tostring(notiz.am):sub(12, 16), notiz.text)
end
local berichtId = MySQL.insert.await([[
INSERT INTO police_bericht
(nummer, einsatz, titel, beschreibung, beteiligte, status, erstellt_von)
VALUES (?, ?, ?, ?, ?, 'entwurf', ?)
]], {
nummer, einsatzId, einsatz.titel,
(einsatz.beschreibung and (einsatz.beschreibung .. '\n\n') or '') ..
(#zeilen > 0 and ('Einsatzverlauf:\n' .. table.concat(zeilen, '\n')) or ''),
json.encode(beteiligte), beamter.charakterId,
})
Hinweise.Erstelle({ charakterId = beamter.charakterId }, {
kategorie = 'bericht', prioritaet = 'normal',
titel = 'Berichtsentwurf angelegt',
text = ('%s wurde als Entwurf vorbereitet und wartet auf deine Ausarbeitung.'):format(nummer),
zielTyp = 'bericht', zielId = berichtId,
})
Protokoll.Schreibe(beamter, 'einsatz', 'Berichtsentwurf angelegt',
{ typ = 'bericht', id = berichtId }, { einsatz = einsatzId })
ergebnis.berichtId = berichtId
ergebnis.berichtNummer = nummer
end
Kern.Loese('einsatz.abgeschlossen', einsatzId, beamter)
VerteileEinsaetze()
return ergebnis
end)
-- =====================================================================
-- EINGEHENDE MELDUNGEN (Dispatch)
-- =====================================================================
--- Legt einen eingehenden Einsatz an, ohne ihn zuzuweisen
--- @param daten table { titel, kategorie, prioritaet, ort, beschreibung, position, quelle }
--- @return number Einsatz-ID
function EinsatzEingang(daten)
local nummer = Kern.ErzeugeKennung('EIN', 'police_einsatz', 'nummer')
local prioritaet = math.max(1, math.min(4, tonumber(daten.prioritaet) or 2))
local einsatzId = MySQL.insert.await([[
INSERT INTO police_einsatz
(nummer, titel, kategorie, prioritaet, ort, pos_x, pos_y, pos_z,
beschreibung, status, quelle)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'offen', ?)
]], {
nummer, daten.titel, KategorieGueltig(daten.kategorie) and daten.kategorie or 'notruf',
prioritaet, daten.ort,
daten.position and daten.position.x, daten.position and daten.position.y,
daten.position and daten.position.z,
daten.beschreibung, daten.quelle or 'notruf',
})
Hinweise.Erstelle(nil, {
kategorie = 'einsatz',
prioritaet = prioritaet == 1 and 'kritisch' or 'hoch',
titel = daten.titel,
text = (daten.ort and ('Ort: ' .. daten.ort .. ' · ') or '') ..
'Eingegangen — Zuweisung durch die Leitstelle.',
zielTyp = 'einsatz', zielId = einsatzId,
})
Kern.Loese('einsatz.eingegangen', einsatzId, daten)
VerteileEinsaetze()
return einsatzId
end
--- Panikknopf eines Beamten (10-13)
RegisterNetEvent('aether:police:panik', function()
local quelle = source
local beamter = Dienst.Hole(quelle)
if not beamter then return end
local koordinaten = GetEntityCoords(GetPlayerPed(quelle))
local einsatzId = EinsatzEingang({
titel = '10-13 — Beamter in Not',
kategorie = 'panik',
prioritaet = 1,
ort = 'Position der Einheit',
beschreibung = ('Panikknopf ausgelöst von %s (%s).')
:format(beamter.name, beamter.dienstnummer),
position = { x = koordinaten.x, y = koordinaten.y, z = koordinaten.z },
quelle = 'panik',
})
-- Alle Beamten im Dienst mit Ton und Wegpunkt alarmieren
for andereQuelle in pairs(Dienst.AlleImDienst()) do
if andereQuelle ~= quelle then
TriggerClientEvent('aether:police:panikAlarm', andereQuelle, {
name = beamter.name, dienstnummer = beamter.dienstnummer,
position = { x = koordinaten.x, y = koordinaten.y, z = koordinaten.z },
einsatzId = einsatzId,
})
end
end
Protokoll.Schreibe(beamter, 'einsatz', 'Panikknopf ausgelöst',
{ typ = 'einsatz', id = einsatzId })
end)