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