- 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>
437 lines
16 KiB
Lua
437 lines
16 KiB
Lua
-- =====================================================================
|
||
-- aether-police / Modul: Statistik
|
||
--
|
||
-- Bewusst eine Auswertung der BEHÖRDE, nicht der Beamten.
|
||
--
|
||
-- Es gibt hier keine Ranglisten, keine Punkte und keine Kennzahl, an
|
||
-- der sich die Arbeit eines einzelnen Beamten messen ließe. Wer wie
|
||
-- viel gearbeitet hat, steht in seiner Personalakte und geht seinen
|
||
-- Vorgesetzten etwas an — nicht einer Tabelle, die jemanden bewertet.
|
||
--
|
||
-- Alle Zahlen sind Zählungen. Keine davon löst etwas aus.
|
||
-- =====================================================================
|
||
|
||
Kern.RegistriereModul({
|
||
id = 'statistik', name = 'Statistik', icon = '📊',
|
||
reihenfolge = 13, recht = 'statistik.sehen',
|
||
beschreibung = 'Auswertung der Behörde über einen Zeitraum',
|
||
})
|
||
|
||
-- Auswählbare Zeiträume in Tagen
|
||
local ZEITRAEUME = { 7, 30, 90 }
|
||
|
||
-- ---------------------------------------------------------------------
|
||
-- Hilfsfunktionen
|
||
-- ---------------------------------------------------------------------
|
||
|
||
--- Begrenzt den Zeitraum auf die zugelassenen Werte
|
||
--- @param tage any Angabe der Oberfläche
|
||
--- @return number
|
||
local function PruefeZeitraum(tage)
|
||
local wert = tonumber(tage) or 30
|
||
for _, erlaubt in ipairs(ZEITRAEUME) do
|
||
if wert == erlaubt then return wert end
|
||
end
|
||
return 30
|
||
end
|
||
|
||
--- Führt eine Zählung aus und liefert immer eine Zahl
|
||
--- @param sql string Abfrage
|
||
--- @param werte table Parameter
|
||
--- @return number
|
||
local function Zaehle(sql, werte)
|
||
return tonumber(MySQL.scalar.await(sql, werte)) or 0
|
||
end
|
||
|
||
--- Baut aus Zeilen { schluessel, anzahl } eine Verteilung mit Anteilen
|
||
--- @param zeilen table Ergebniszeilen
|
||
--- @param namen table|nil Übersetzungstabelle für die Schlüssel
|
||
--- @return table
|
||
local function Verteilung(zeilen, namen)
|
||
local gesamt = 0
|
||
for _, zeile in ipairs(zeilen or {}) do
|
||
gesamt = gesamt + (tonumber(zeile.anzahl) or 0)
|
||
end
|
||
|
||
local liste = {}
|
||
for _, zeile in ipairs(zeilen or {}) do
|
||
local anzahl = tonumber(zeile.anzahl) or 0
|
||
liste[#liste + 1] = {
|
||
schluessel = zeile.schluessel,
|
||
name = (namen and namen[zeile.schluessel]) or zeile.schluessel or 'unbekannt',
|
||
anzahl = anzahl,
|
||
anteil = gesamt > 0 and math.floor((anzahl / gesamt) * 100 + 0.5) or 0,
|
||
}
|
||
end
|
||
|
||
return { eintraege = liste, gesamt = gesamt }
|
||
end
|
||
|
||
--- Liest die Namen aus einer Konfigurationsliste { id, name }
|
||
--- @param liste table Konfiguration
|
||
--- @return table [id] = name
|
||
local function NamenAus(liste)
|
||
local namen = {}
|
||
for _, eintrag in ipairs(liste or {}) do namen[eintrag.id] = eintrag.name end
|
||
return namen
|
||
end
|
||
|
||
-- =====================================================================
|
||
-- ÜBERSICHT
|
||
-- =====================================================================
|
||
|
||
Kern.RegistriereAktion('statistik', 'uebersicht', nil, function(quelle, beamter, daten)
|
||
local tage = PruefeZeitraum(daten.tage)
|
||
|
||
-- --- Einsätze ---
|
||
local einsaetze = {
|
||
gesamt = Zaehle([[
|
||
SELECT COUNT(*) FROM police_einsatz
|
||
WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
]], { tage }),
|
||
offen = Zaehle([[
|
||
SELECT COUNT(*) FROM police_einsatz WHERE geschlossen_am IS NULL
|
||
]], {}),
|
||
}
|
||
|
||
einsaetze.nachKategorie = Verteilung(MySQL.query.await([[
|
||
SELECT kategorie AS schluessel, COUNT(*) AS anzahl
|
||
FROM police_einsatz
|
||
WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
GROUP BY kategorie ORDER BY anzahl DESC
|
||
]], { tage }), NamenAus(Polizei.Config.Einsatzkategorien))
|
||
|
||
einsaetze.nachPrioritaet = Verteilung(MySQL.query.await([[
|
||
SELECT prioritaet AS schluessel, COUNT(*) AS anzahl
|
||
FROM police_einsatz
|
||
WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
GROUP BY prioritaet ORDER BY prioritaet
|
||
]], { tage }))
|
||
|
||
-- Wie lange bleibt ein Einsatz offen? Prozessdauer, keine Bewertung.
|
||
einsaetze.laufzeitMinuten = tonumber(MySQL.scalar.await([[
|
||
SELECT AVG(TIMESTAMPDIFF(MINUTE, erstellt_am, geschlossen_am))
|
||
FROM police_einsatz
|
||
WHERE geschlossen_am IS NOT NULL
|
||
AND erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
]], { tage })) or 0
|
||
|
||
-- --- Anzeigen ---
|
||
local anzeigen = {
|
||
gesamt = Zaehle([[
|
||
SELECT COUNT(*) FROM police_anzeige
|
||
WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
]], { tage }),
|
||
bestaetigt = Zaehle([[
|
||
SELECT COUNT(*) FROM police_anzeige
|
||
WHERE status = 'bestaetigt' AND erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
]], { tage }),
|
||
entwurf = Zaehle([[
|
||
SELECT COUNT(*) FROM police_anzeige WHERE status = 'entwurf'
|
||
]], {}),
|
||
}
|
||
|
||
-- Wie oft weicht die Entscheidung vom Katalogvorschlag ab?
|
||
-- Das ist eine Aussage über den KATALOG, nicht über die Beamten:
|
||
-- viele Abweichungen heißen, dass der Vorschlag nicht passt.
|
||
anzeigen.abweichungen = Zaehle([[
|
||
SELECT COUNT(*) FROM police_anzeige
|
||
WHERE status = 'bestaetigt'
|
||
AND erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
AND (haft_final <> haft_empfohlen OR geld_final <> geld_empfohlen)
|
||
]], { tage })
|
||
|
||
anzeigen.haftDurchschnitt = tonumber(MySQL.scalar.await([[
|
||
SELECT AVG(haft_final) FROM police_anzeige
|
||
WHERE status = 'bestaetigt' AND haft_final IS NOT NULL
|
||
AND erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
]], { tage })) or 0
|
||
|
||
anzeigen.geldDurchschnitt = tonumber(MySQL.scalar.await([[
|
||
SELECT AVG(geld_final) FROM police_anzeige
|
||
WHERE status = 'bestaetigt' AND geld_final IS NOT NULL
|
||
AND erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
]], { tage })) or 0
|
||
|
||
-- --- Gewahrsam ---
|
||
local gewahrsam = {
|
||
einweisungen = Zaehle([[
|
||
SELECT COUNT(*) FROM police_gewahrsam
|
||
WHERE beginn >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
]], { tage }),
|
||
laufend = Zaehle('SELECT COUNT(*) FROM police_gewahrsam WHERE entlassen_am IS NULL', {}),
|
||
}
|
||
|
||
gewahrsam.haftDurchschnitt = tonumber(MySQL.scalar.await([[
|
||
SELECT AVG(haft_minuten) FROM police_gewahrsam
|
||
WHERE beginn >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
]], { tage })) or 0
|
||
|
||
-- Wie lange nach Fristablauf wurde tatsächlich entlassen?
|
||
-- Reine Prozessdauer — das System entlässt niemanden von selbst,
|
||
-- deshalb ist der Verzug eine Frage der Erreichbarkeit, nicht der Schuld.
|
||
gewahrsam.verzugMinuten = tonumber(MySQL.scalar.await([[
|
||
SELECT AVG(TIMESTAMPDIFF(MINUTE, faellig_am, entlassen_am))
|
||
FROM police_gewahrsam
|
||
WHERE entlassen_am IS NOT NULL
|
||
AND beginn >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
]], { tage })) or 0
|
||
|
||
gewahrsam.vorzeitig = Zaehle([[
|
||
SELECT COUNT(*) FROM police_gewahrsam
|
||
WHERE entlassen_am IS NOT NULL AND entlassen_am < faellig_am
|
||
AND beginn >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
]], { tage })
|
||
|
||
-- --- Akten, Berichte, Fahndungen ---
|
||
local vorgaenge = {
|
||
akten = Zaehle([[
|
||
SELECT COUNT(*) FROM police_akte
|
||
WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
]], { tage }),
|
||
aktenOffen = Zaehle("SELECT COUNT(*) FROM police_akte WHERE status <> 'geschlossen'", {}),
|
||
berichte = Zaehle([[
|
||
SELECT COUNT(*) FROM police_bericht
|
||
WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
]], { tage }),
|
||
berichteEntwurf = Zaehle("SELECT COUNT(*) FROM police_bericht WHERE status = 'entwurf'", {}),
|
||
fahndungen = Zaehle([[
|
||
SELECT COUNT(*) FROM police_fahndung
|
||
WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
]], { tage }),
|
||
fahndungenAktiv = Zaehle('SELECT COUNT(*) FROM police_fahndung WHERE aktiv = 1', {}),
|
||
beweise = Zaehle([[
|
||
SELECT COUNT(*) FROM police_beweis
|
||
WHERE erfasst_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
]], { tage }),
|
||
asservate = Zaehle("SELECT COUNT(*) FROM police_asservat WHERE status = 'eingelagert'", {}),
|
||
}
|
||
|
||
vorgaenge.aktenNachTyp = Verteilung(MySQL.query.await([[
|
||
SELECT typ AS schluessel, COUNT(*) AS anzahl
|
||
FROM police_akte
|
||
WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
GROUP BY typ ORDER BY anzahl DESC
|
||
]], { tage }), NamenAus(Polizei.Config.Aktenarten))
|
||
|
||
-- --- Personal und Dienst ---
|
||
local personal = {
|
||
bestand = Zaehle('SELECT COUNT(*) FROM police_personal WHERE aktiv = 1', {}),
|
||
imDienst = 0,
|
||
}
|
||
for _ in pairs(Dienst.AlleImDienst()) do personal.imDienst = personal.imDienst + 1 end
|
||
|
||
personal.nachAbteilung = Verteilung(MySQL.query.await([[
|
||
SELECT abteilung AS schluessel, COUNT(*) AS anzahl
|
||
FROM police_personal WHERE aktiv = 1
|
||
GROUP BY abteilung ORDER BY anzahl DESC
|
||
]], {}), NamenAus(Polizei.Config.Abteilungen))
|
||
|
||
personal.dienstStunden = math.floor((tonumber(MySQL.scalar.await([[
|
||
SELECT COALESCE(SUM(sekunden), 0) FROM police_dienst
|
||
WHERE beginn >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
]], { tage })) or 0) / 3600)
|
||
|
||
-- --- Verlauf: Einsätze und Anzeigen je Tag ---
|
||
local verlauf = MySQL.query.await([[
|
||
SELECT tag,
|
||
SUM(einsatz) AS einsaetze,
|
||
SUM(anzeige) AS anzeigen
|
||
FROM (
|
||
SELECT DATE(erstellt_am) AS tag, 1 AS einsatz, 0 AS anzeige
|
||
FROM police_einsatz
|
||
WHERE erstellt_am >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||
UNION ALL
|
||
SELECT DATE(erstellt_am) AS tag, 0 AS einsatz, 1 AS anzeige
|
||
FROM police_anzeige
|
||
WHERE erstellt_am >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||
) AS zusammen
|
||
GROUP BY tag ORDER BY tag
|
||
]], { tage, tage }) or {}
|
||
|
||
return {
|
||
tage = tage,
|
||
zeitraeume = ZEITRAEUME,
|
||
einsaetze = einsaetze,
|
||
anzeigen = anzeigen,
|
||
gewahrsam = gewahrsam,
|
||
vorgaenge = vorgaenge,
|
||
personal = personal,
|
||
verlauf = verlauf,
|
||
}
|
||
end)
|
||
|
||
-- =====================================================================
|
||
-- DIENSTABDECKUNG
|
||
--
|
||
-- Wann ist wie viel Personal da? Ein Planungswerkzeug für die Leitung.
|
||
-- Die Zahlen sagen nichts darüber aus, WER Dienst hatte.
|
||
-- =====================================================================
|
||
|
||
Kern.RegistriereAktion('statistik', 'abdeckung', nil, function(quelle, beamter, daten)
|
||
local tage = PruefeZeitraum(daten.tage)
|
||
|
||
local dienste = MySQL.query.await([[
|
||
SELECT UNIX_TIMESTAMP(beginn) AS beginn,
|
||
UNIX_TIMESTAMP(COALESCE(ende, NOW())) AS ende,
|
||
abteilung
|
||
FROM police_dienst
|
||
WHERE beginn >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
LIMIT 5000
|
||
]], { tage }) or {}
|
||
|
||
-- Dienstsekunden auf die 24 Stunden des Tages verteilen
|
||
local stunden = {}
|
||
for i = 0, 23 do stunden[i] = 0 end
|
||
|
||
for _, dienst in ipairs(dienste) do
|
||
local beginn = tonumber(dienst.beginn) or 0
|
||
local ende = tonumber(dienst.ende) or beginn
|
||
|
||
-- Schicht stundenweise durchlaufen, damit Nachtschichten korrekt zählen
|
||
local zeiger = beginn
|
||
local schutz = 0
|
||
while zeiger < ende and schutz < 500 do
|
||
schutz = schutz + 1
|
||
|
||
local stunde = tonumber(os.date('%H', zeiger)) or 0
|
||
-- Ende der laufenden Stunde
|
||
local grenze = zeiger + (3600 - (zeiger % 3600))
|
||
if grenze > ende then grenze = ende end
|
||
|
||
stunden[stunde] = stunden[stunde] + (grenze - zeiger)
|
||
zeiger = grenze
|
||
end
|
||
end
|
||
|
||
-- In durchschnittlich gleichzeitig anwesende Beamte umrechnen
|
||
local liste, hoechstwert = {}, 0
|
||
for i = 0, 23 do
|
||
-- Sekunden geteilt durch (Tage × 3600) = mittlere Besetzung dieser Stunde
|
||
local mittel = stunden[i] / (tage * 3600)
|
||
if mittel > hoechstwert then hoechstwert = mittel end
|
||
liste[#liste + 1] = {
|
||
stunde = i,
|
||
mittel = math.floor(mittel * 100 + 0.5) / 100,
|
||
}
|
||
end
|
||
|
||
return {
|
||
tage = tage,
|
||
stunden = liste,
|
||
hoechstwert = math.floor(hoechstwert * 100 + 0.5) / 100,
|
||
schichten = #dienste,
|
||
nachAbteilung = Verteilung(MySQL.query.await([[
|
||
SELECT abteilung AS schluessel, COUNT(*) AS anzahl
|
||
FROM police_dienst
|
||
WHERE beginn >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
GROUP BY abteilung ORDER BY anzahl DESC
|
||
]], { tage }), NamenAus(Polizei.Config.Abteilungen)),
|
||
}
|
||
end)
|
||
|
||
-- =====================================================================
|
||
-- KATALOGAUSWERTUNG
|
||
--
|
||
-- Welche Delikte kommen wie oft vor und wie oft wird vom Vorschlag
|
||
-- abgewichen? Grundlage für die Pflege des Katalogs — ausdrücklich
|
||
-- keine Aussage über die Beamten, die entschieden haben.
|
||
-- =====================================================================
|
||
|
||
Kern.RegistriereAktion('statistik', 'katalog', nil, function(quelle, beamter, daten)
|
||
local tage = PruefeZeitraum(daten.tage)
|
||
|
||
local anzeigen = MySQL.query.await([[
|
||
SELECT delikte, haft_empfohlen, geld_empfohlen, haft_final, geld_final
|
||
FROM police_anzeige
|
||
WHERE status = 'bestaetigt'
|
||
AND erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
LIMIT 2000
|
||
]], { tage }) or {}
|
||
|
||
-- Häufigkeit je Delikt und Abweichungen der Anzeigen, in denen es vorkam
|
||
local zaehler = {}
|
||
|
||
for _, anzeige in ipairs(anzeigen) do
|
||
local erfolg, liste = pcall(json.decode, anzeige.delikte)
|
||
if erfolg and type(liste) == 'table' then
|
||
-- Wich die Entscheidung dieser Anzeige vom Vorschlag ab?
|
||
local abweichung =
|
||
(tonumber(anzeige.haft_final) or 0) ~= (tonumber(anzeige.haft_empfohlen) or 0)
|
||
or (tonumber(anzeige.geld_final) or 0) ~= (tonumber(anzeige.geld_empfohlen) or 0)
|
||
|
||
for _, delikt in ipairs(liste) do
|
||
local kennung = delikt.kennung or '?'
|
||
zaehler[kennung] = zaehler[kennung] or {
|
||
kennung = kennung,
|
||
name = delikt.name or kennung,
|
||
paragraph = delikt.paragraph,
|
||
anzahl = 0, abweichungen = 0,
|
||
}
|
||
zaehler[kennung].anzahl = zaehler[kennung].anzahl + 1
|
||
if abweichung then
|
||
zaehler[kennung].abweichungen = zaehler[kennung].abweichungen + 1
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
local delikte = {}
|
||
for _, eintrag in pairs(zaehler) do
|
||
eintrag.abweichungsAnteil = eintrag.anzahl > 0
|
||
and math.floor((eintrag.abweichungen / eintrag.anzahl) * 100 + 0.5) or 0
|
||
delikte[#delikte + 1] = eintrag
|
||
end
|
||
|
||
table.sort(delikte, function(a, b) return a.anzahl > b.anzahl end)
|
||
|
||
-- Nur die häufigsten übertragen
|
||
local gekuerzt = {}
|
||
for i = 1, math.min(#delikte, 30) do gekuerzt[i] = delikte[i] end
|
||
|
||
-- Delikte im Katalog, die im Zeitraum nie verwendet wurden
|
||
local ungenutzt = {}
|
||
local katalog = MySQL.query.await(
|
||
'SELECT kennung, bezeichnung, kategorie FROM police_katalog WHERE aktiv = 1') or {}
|
||
for _, eintrag in ipairs(katalog) do
|
||
if not zaehler[eintrag.kennung] then
|
||
ungenutzt[#ungenutzt + 1] = eintrag
|
||
end
|
||
end
|
||
|
||
return {
|
||
tage = tage,
|
||
delikte = gekuerzt,
|
||
anzeigen = #anzeigen,
|
||
ungenutzt = ungenutzt,
|
||
katalogGesamt = #katalog,
|
||
}
|
||
end)
|
||
|
||
-- ---------------------------------------------------------------------
|
||
-- Schnittstelle für andere Ressourcen
|
||
-- ---------------------------------------------------------------------
|
||
|
||
--- Liefert die Kennzahlen des Zeitraums als einfache Tabelle
|
||
--- @param tage number Zeitraum in Tagen (7, 30 oder 90)
|
||
--- @return table
|
||
exports('HoleKennzahlen', function(tage)
|
||
local geprueft = PruefeZeitraum(tage)
|
||
return {
|
||
tage = geprueft,
|
||
einsaetze = Zaehle([[
|
||
SELECT COUNT(*) FROM police_einsatz
|
||
WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
]], { geprueft }),
|
||
anzeigen = Zaehle([[
|
||
SELECT COUNT(*) FROM police_anzeige
|
||
WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
]], { geprueft }),
|
||
einweisungen = Zaehle([[
|
||
SELECT COUNT(*) FROM police_gewahrsam
|
||
WHERE beginn >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
||
]], { geprueft }),
|
||
personal = Zaehle('SELECT COUNT(*) FROM police_personal WHERE aktiv = 1', {}),
|
||
}
|
||
end)
|