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

437 lines
16 KiB
Lua
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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