-- ===================================================================== -- 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)