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

583 lines
22 KiB
Lua

-- =====================================================================
-- aether-police / Modul: Berichtswesen
--
-- Ein Bericht durchläuft drei Stufen: Entwurf → Eingereicht → Freigegeben.
-- Keine davon wird automatisch erreicht. Auch die Freigabe ist eine
-- bewusste Handlung eines berechtigten Beamten und wird mit Namen
-- festgehalten. Unterschriften sind verbindlich und nicht widerrufbar.
-- =====================================================================
Kern.RegistriereModul({
id = 'bericht', name = 'Berichte', icon = '📄',
reihenfolge = 8, recht = 'bericht.erstellen',
beschreibung = 'Einsatzberichte verfassen und freigeben',
})
-- ---------------------------------------------------------------------
-- Hilfsfunktionen
-- ---------------------------------------------------------------------
--- Entschlüsselt ein JSON-Feld sicher zu einer Liste
--- @param roh string|nil
--- @return table
local function ZuListe(roh)
if not roh then return {} end
local erfolg, liste = pcall(json.decode, roh)
return (erfolg and type(liste) == 'table') and liste or {}
end
--- Lädt einen Bericht samt aufbereiteter Felder
--- @param berichtId number
--- @return table|nil
local function LadeBericht(berichtId)
local zeilen = MySQL.query.await([[
SELECT b.*, c.firstname, c.lastname, p.dienstnummer,
e.nummer AS einsatz_nummer, e.titel AS einsatz_titel,
a.aktenzeichen, a.titel AS akte_titel
FROM police_bericht b
LEFT JOIN aether_characters c ON c.id = b.erstellt_von
LEFT JOIN police_personal p ON p.`character` = b.erstellt_von
LEFT JOIN police_einsatz e ON e.id = b.einsatz
LEFT JOIN police_akte a ON a.id = b.akte
WHERE b.id = ?
]], { berichtId })
if not zeilen or not zeilen[1] then return nil end
local bericht = zeilen[1]
bericht.beteiligte = ZuListe(bericht.beteiligte)
bericht.anlagen = ZuListe(bericht.anlagen)
bericht.unterschriften = ZuListe(bericht.unterschriften)
return bericht
end
--- Darf der Beamte diesen Bericht inhaltlich ändern?
--- @param bericht table
--- @param beamter table
--- @return boolean
local function DarfBearbeiten(bericht, beamter)
-- Freigegebene Berichte sind gesperrt — auch für den Verfasser
if bericht.status == 'freigegeben' then return false end
if bericht.erstellt_von == beamter.charakterId then return true end
-- Fremde Berichte darf nur bearbeiten, wer auch freigeben darf
return Rechte.Hat(beamter, 'bericht.freigeben')
end
-- =====================================================================
-- ÜBERSICHT
-- =====================================================================
Kern.RegistriereAktion('bericht', 'liste', nil, function(quelle, beamter, daten)
local bedingungen, werte = { '1=1' }, {}
if daten.status and daten.status ~= '' then
bedingungen[#bedingungen + 1] = 'b.status = ?'
werte[#werte + 1] = daten.status
end
if daten.nurEigene then
bedingungen[#bedingungen + 1] = 'b.erstellt_von = ?'
werte[#werte + 1] = beamter.charakterId
end
if type(daten.begriff) == 'string' and #daten.begriff >= 2 then
bedingungen[#bedingungen + 1] = '(b.titel LIKE ? OR b.nummer LIKE ?)'
local muster = '%' .. daten.begriff .. '%'
werte[#werte + 1] = muster
werte[#werte + 1] = muster
end
local berichte = MySQL.query.await(([[
SELECT b.id, b.nummer, b.titel, b.status, b.erstellt_am, b.erstellt_von,
b.unterschriften, b.einsatz, b.akte,
c.firstname, c.lastname, p.dienstnummer,
e.nummer AS einsatz_nummer
FROM police_bericht b
LEFT JOIN aether_characters c ON c.id = b.erstellt_von
LEFT JOIN police_personal p ON p.`character` = b.erstellt_von
LEFT JOIN police_einsatz e ON e.id = b.einsatz
WHERE %s
ORDER BY
CASE b.status WHEN 'entwurf' THEN 0 WHEN 'eingereicht' THEN 1 ELSE 2 END,
b.id DESC
LIMIT 80
]]):format(table.concat(bedingungen, ' AND ')), werte) or {}
for _, bericht in ipairs(berichte) do
bericht.unterschriften = ZuListe(bericht.unterschriften)
bericht.eigener = bericht.erstellt_von == beamter.charakterId
end
-- Zahlen für die Übersicht
local offen = MySQL.scalar.await(
"SELECT COUNT(*) FROM police_bericht WHERE status = 'entwurf' AND erstellt_von = ?",
{ beamter.charakterId }) or 0
local zurPruefung = MySQL.scalar.await(
"SELECT COUNT(*) FROM police_bericht WHERE status = 'eingereicht'") or 0
return {
berichte = berichte,
eigeneOffen = offen,
zurPruefung = zurPruefung,
rechte = {
erstellen = Rechte.Hat(beamter, 'bericht.erstellen'),
freigeben = Rechte.Hat(beamter, 'bericht.freigeben'),
},
}
end)
Kern.RegistriereAktion('bericht', 'oeffnen', nil, function(quelle, beamter, daten)
local berichtId = tonumber(daten.id)
if not berichtId then return nil, 'Ungültige Kennung.' end
local bericht = LadeBericht(berichtId)
if not bericht then return nil, 'Bericht nicht gefunden.' end
-- Habe ich bereits unterschrieben?
local habeUnterschrieben = false
for _, unterschrift in ipairs(bericht.unterschriften) do
if unterschrift.charakterId == beamter.charakterId then
habeUnterschrieben = true
break
end
end
return {
bericht = bericht,
habeUnterschrieben = habeUnterschrieben,
rechte = {
bearbeiten = DarfBearbeiten(bericht, beamter),
freigeben = Rechte.Hat(beamter, 'bericht.freigeben'),
},
}
end)
-- =====================================================================
-- ANLEGEN
-- =====================================================================
Kern.RegistriereAktion('bericht', 'erstellen', 'bericht.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
-- Bezüge prüfen, falls angegeben
local einsatzId = tonumber(daten.einsatz)
if einsatzId then
local vorhanden = MySQL.scalar.await('SELECT id FROM police_einsatz WHERE id = ?', { einsatzId })
if not vorhanden then return nil, 'Der angegebene Einsatz existiert nicht.' end
end
local akteId = tonumber(daten.akte)
if akteId then
local vorhanden = MySQL.scalar.await('SELECT id FROM police_akte WHERE id = ?', { akteId })
if not vorhanden then return nil, 'Die angegebene Akte existiert nicht.' end
end
local nummer = Kern.ErzeugeKennung('BER', 'police_bericht', 'nummer')
-- Der Verfasser ist von Beginn an als beteiligter Beamter geführt
local beteiligte = { {
typ = 'beamter', charakterId = beamter.charakterId,
name = beamter.name, dienstnummer = beamter.dienstnummer,
rolle = 'Verfasser',
} }
local berichtId = MySQL.insert.await([[
INSERT INTO police_bericht
(nummer, einsatz, akte, titel, beschreibung, massnahmen, beteiligte,
anlagen, unterschriften, status, erstellt_von)
VALUES (?, ?, ?, ?, ?, ?, ?, '[]', '[]', 'entwurf', ?)
]], {
nummer, einsatzId, akteId, titel,
type(daten.beschreibung) == 'string' and daten.beschreibung or nil,
type(daten.massnahmen) == 'string' and daten.massnahmen or nil,
json.encode(beteiligte), beamter.charakterId,
})
Protokoll.Schreibe(beamter, 'bericht', 'Bericht angelegt',
{ typ = 'bericht', id = berichtId }, { nummer = nummer })
Kern.Loese('bericht.erstellt', berichtId, beamter)
return { id = berichtId, nummer = nummer }
end)
-- =====================================================================
-- BEARBEITEN
--
-- Freigegebene Berichte sind gesperrt — auch für den Verfasser.
-- =====================================================================
Kern.RegistriereAktion('bericht', 'speichern', 'bericht.erstellen', function(quelle, beamter, daten)
local berichtId = tonumber(daten.id)
if not berichtId then return nil, 'Ungültige Kennung.' end
local bericht = LadeBericht(berichtId)
if not bericht then return nil, 'Bericht nicht gefunden.' end
if not DarfBearbeiten(bericht, beamter) then
return nil, bericht.status == 'freigegeben'
and 'Freigegebene Berichte können nicht mehr geändert werden.'
or 'Du kannst nur eigene Berichte bearbeiten.'
end
-- Liegen bereits Unterschriften vor, ist eine Änderung heikel
if #bericht.unterschriften > 0 then
local grund = type(daten.grund) == 'string' and daten.grund or ''
if #grund < 5 then
return nil, 'Der Bericht wurde bereits unterschrieben. Eine Änderung erfordert eine Begründung.'
end
end
MySQL.update.await([[
UPDATE police_bericht SET titel = ?, beschreibung = ?, massnahmen = ? WHERE id = ?
]], {
type(daten.titel) == 'string' and daten.titel:sub(1, 160) or bericht.titel,
type(daten.beschreibung) == 'string' and daten.beschreibung or nil,
type(daten.massnahmen) == 'string' and daten.massnahmen or nil,
berichtId,
})
Protokoll.Schreibe(beamter, 'bericht', 'Bericht bearbeitet',
{ typ = 'bericht', id = berichtId },
{ unterschriften = #bericht.unterschriften },
type(daten.grund) == 'string' and daten.grund or nil)
return { ok = true }
end)
-- ---------------------------------------------------------------------
-- Beteiligte
-- ---------------------------------------------------------------------
Kern.RegistriereAktion('bericht', 'beteiligtHinzu', 'bericht.erstellen', function(quelle, beamter, daten)
local berichtId = tonumber(daten.id)
if not berichtId then return nil, 'Ungültige Kennung.' end
local bericht = LadeBericht(berichtId)
if not bericht then return nil, 'Bericht nicht gefunden.' end
if not DarfBearbeiten(bericht, beamter) then return nil, 'Keine Berechtigung.' end
local typ = (daten.typ == 'beamter') and 'beamter' or 'person'
local name = type(daten.name) == 'string'
and daten.name:gsub('^%s+', ''):gsub('%s+$', '') or ''
if #name < 2 then return nil, 'Bitte einen Namen angeben.' end
local charakterId = tonumber(daten.charakterId)
-- Doppelte Einträge vermeiden
for _, eintrag in ipairs(bericht.beteiligte) do
if charakterId and eintrag.charakterId == charakterId then
return nil, 'Diese Person ist bereits aufgeführt.'
end
end
table.insert(bericht.beteiligte, {
typ = typ, charakterId = charakterId, name = name:sub(1, 64),
rolle = type(daten.rolle) == 'string' and daten.rolle:sub(1, 48) or nil,
dienstnummer = type(daten.dienstnummer) == 'string' and daten.dienstnummer:sub(1, 16) or nil,
})
MySQL.update.await('UPDATE police_bericht SET beteiligte = ? WHERE id = ?',
{ json.encode(bericht.beteiligte), berichtId })
Protokoll.Schreibe(beamter, 'bericht', 'Beteiligten ergänzt',
{ typ = 'bericht', id = berichtId }, { name = name, rolle = daten.rolle })
return { ok = true }
end)
Kern.RegistriereAktion('bericht', 'beteiligtEntfernen', 'bericht.erstellen', function(quelle, beamter, daten)
local berichtId = tonumber(daten.id)
local index = tonumber(daten.index)
if not berichtId or not index then return nil, 'Unvollständige Angaben.' end
local bericht = LadeBericht(berichtId)
if not bericht then return nil, 'Bericht nicht gefunden.' end
if not DarfBearbeiten(bericht, beamter) then return nil, 'Keine Berechtigung.' end
local eintrag = bericht.beteiligte[index]
if not eintrag then return nil, 'Eintrag nicht gefunden.' end
-- Der Verfasser bleibt immer aufgeführt
if eintrag.charakterId == bericht.erstellt_von then
return nil, 'Der Verfasser kann nicht entfernt werden.'
end
table.remove(bericht.beteiligte, index)
MySQL.update.await('UPDATE police_bericht SET beteiligte = ? WHERE id = ?',
{ json.encode(bericht.beteiligte), berichtId })
Protokoll.Schreibe(beamter, 'bericht', 'Beteiligten entfernt',
{ typ = 'bericht', id = berichtId }, { name = eintrag.name })
return { ok = true }
end)
-- ---------------------------------------------------------------------
-- Anlagen (Dateien und Beweisbezüge)
-- ---------------------------------------------------------------------
Kern.RegistriereAktion('bericht', 'anlageHinzu', 'bericht.erstellen', function(quelle, beamter, daten)
local berichtId = tonumber(daten.id)
if not berichtId then return nil, 'Ungültige Kennung.' end
local bericht = LadeBericht(berichtId)
if not bericht then return nil, 'Bericht nicht gefunden.' end
if not DarfBearbeiten(bericht, beamter) then return nil, 'Keine Berechtigung.' end
local eintrag
if daten.typ == 'beweis' then
local beweisId = tonumber(daten.beweisId)
if not beweisId then return nil, 'Kein Beweismittel gewählt.' end
local beweis = MySQL.query.await(
'SELECT kennung, bezeichnung, art FROM police_beweis WHERE id = ?', { beweisId })
if not beweis or not beweis[1] then return nil, 'Beweismittel nicht gefunden.' end
for _, vorhanden in ipairs(bericht.anlagen) do
if vorhanden.typ == 'beweis' and vorhanden.beweisId == beweisId then
return nil, 'Dieses Beweismittel ist bereits aufgeführt.'
end
end
eintrag = {
typ = 'beweis', beweisId = beweisId,
kennung = beweis[1].kennung, titel = beweis[1].bezeichnung, art = beweis[1].art,
}
else
local titel = type(daten.titel) == 'string'
and daten.titel:gsub('^%s+', ''):gsub('%s+$', '') or ''
if #titel < 2 then return nil, 'Bitte eine Bezeichnung angeben.' end
eintrag = {
typ = 'datei', titel = titel:sub(1, 120),
adresse = type(daten.adresse) == 'string' and daten.adresse:sub(1, 2000) or nil,
}
end
table.insert(bericht.anlagen, eintrag)
MySQL.update.await('UPDATE police_bericht SET anlagen = ? WHERE id = ?',
{ json.encode(bericht.anlagen), berichtId })
Protokoll.Schreibe(beamter, 'bericht', 'Anlage ergänzt',
{ typ = 'bericht', id = berichtId }, eintrag)
return { ok = true }
end)
Kern.RegistriereAktion('bericht', 'anlageEntfernen', 'bericht.erstellen', function(quelle, beamter, daten)
local berichtId = tonumber(daten.id)
local index = tonumber(daten.index)
if not berichtId or not index then return nil, 'Unvollständige Angaben.' end
local bericht = LadeBericht(berichtId)
if not bericht then return nil, 'Bericht nicht gefunden.' end
if not DarfBearbeiten(bericht, beamter) then return nil, 'Keine Berechtigung.' end
local eintrag = bericht.anlagen[index]
if not eintrag then return nil, 'Anlage nicht gefunden.' end
table.remove(bericht.anlagen, index)
MySQL.update.await('UPDATE police_bericht SET anlagen = ? WHERE id = ?',
{ json.encode(bericht.anlagen), berichtId })
Protokoll.Schreibe(beamter, 'bericht', 'Anlage entfernt',
{ typ = 'bericht', id = berichtId }, { titel = eintrag.titel })
return { ok = true }
end)
-- =====================================================================
-- UNTERSCHREIBEN
--
-- Eine Unterschrift ist verbindlich und wird nicht zurückgenommen.
-- =====================================================================
Kern.RegistriereAktion('bericht', 'unterschreiben', nil, function(quelle, beamter, daten)
local berichtId = tonumber(daten.id)
if not berichtId then return nil, 'Ungültige Kennung.' end
local bericht = LadeBericht(berichtId)
if not bericht then return nil, 'Bericht nicht gefunden.' end
if bericht.status == 'freigegeben' then
return nil, 'Der Bericht ist bereits freigegeben.'
end
for _, unterschrift in ipairs(bericht.unterschriften) do
if unterschrift.charakterId == beamter.charakterId then
return nil, 'Du hast bereits unterschrieben.'
end
end
table.insert(bericht.unterschriften, {
charakterId = beamter.charakterId,
name = beamter.name,
dienstnummer = beamter.dienstnummer,
rolle = beamter.rolleName,
am = os.date('%Y-%m-%d %H:%M:%S'),
})
MySQL.update.await('UPDATE police_bericht SET unterschriften = ? WHERE id = ?',
{ json.encode(bericht.unterschriften), berichtId })
Protokoll.Schreibe(beamter, 'bericht', 'Bericht unterschrieben',
{ typ = 'bericht', id = berichtId }, { nummer = bericht.nummer })
return { ok = true }
end)
-- =====================================================================
-- EINREICHEN UND FREIGEBEN
-- =====================================================================
Kern.RegistriereAktion('bericht', 'einreichen', 'bericht.erstellen', function(quelle, beamter, daten)
local berichtId = tonumber(daten.id)
if not berichtId then return nil, 'Ungültige Kennung.' end
local bericht = LadeBericht(berichtId)
if not bericht then return nil, 'Bericht nicht gefunden.' end
if bericht.status ~= 'entwurf' then
return nil, 'Nur Entwürfe können eingereicht werden.'
end
if bericht.erstellt_von ~= beamter.charakterId
and not Rechte.Hat(beamter, 'bericht.freigeben') then
return nil, 'Nur der Verfasser kann seinen Bericht einreichen.'
end
if not bericht.beschreibung or #bericht.beschreibung < 20 then
return nil, 'Der Bericht enthält noch keine ausreichende Beschreibung.'
end
if #bericht.unterschriften == 0 then
return nil, 'Bitte unterschreibe den Bericht, bevor du ihn einreichst.'
end
MySQL.update.await("UPDATE police_bericht SET status = 'eingereicht' WHERE id = ?", { berichtId })
Protokoll.Schreibe(beamter, 'bericht', 'Bericht eingereicht',
{ typ = 'bericht', id = berichtId }, { nummer = bericht.nummer })
-- Die Führung darüber informieren, dass eine Prüfung ansteht
Hinweise.Erstelle(nil, {
kategorie = 'bericht', prioritaet = 'normal',
titel = 'Bericht zur Prüfung eingereicht',
text = ('%s „%s" von %s wartet auf Freigabe.')
:format(bericht.nummer, bericht.titel, beamter.dienstnummer),
zielTyp = 'bericht', zielId = berichtId,
})
return { ok = true }
end)
Kern.RegistriereAktion('bericht', 'freigeben', 'bericht.freigeben', function(quelle, beamter, daten)
local berichtId = tonumber(daten.id)
if not berichtId then return nil, 'Ungültige Kennung.' end
local bericht = LadeBericht(berichtId)
if not bericht then return nil, 'Bericht nicht gefunden.' end
if bericht.status == 'freigegeben' then
return nil, 'Der Bericht ist bereits freigegeben.'
end
if bericht.status ~= 'eingereicht' then
return nil, 'Nur eingereichte Berichte können freigegeben werden.'
end
-- Mit der Freigabe unterschreibt der Freigebende zugleich
local schonDrin = false
for _, unterschrift in ipairs(bericht.unterschriften) do
if unterschrift.charakterId == beamter.charakterId then schonDrin = true break end
end
if not schonDrin then
table.insert(bericht.unterschriften, {
charakterId = beamter.charakterId, name = beamter.name,
dienstnummer = beamter.dienstnummer, rolle = beamter.rolleName,
am = os.date('%Y-%m-%d %H:%M:%S'), freigabe = true,
})
end
MySQL.update.await(
"UPDATE police_bericht SET status = 'freigegeben', unterschriften = ? WHERE id = ?",
{ json.encode(bericht.unterschriften), berichtId })
Protokoll.Schreibe(beamter, 'bericht', 'Bericht freigegeben',
{ typ = 'bericht', id = berichtId }, { nummer = bericht.nummer }, daten.vermerk)
Hinweise.Erstelle({ charakterId = bericht.erstellt_von }, {
kategorie = 'bericht', prioritaet = 'normal',
titel = 'Bericht freigegeben',
text = ('%s wurde von %s freigegeben.'):format(bericht.nummer, beamter.dienstnummer),
zielTyp = 'bericht', zielId = berichtId,
})
Kern.Loese('bericht.freigegeben', berichtId, beamter)
return { ok = true }
end)
--- Zurück in den Entwurf (Nachbesserung anfordern)
Kern.RegistriereAktion('bericht', 'zurueckweisen', 'bericht.freigeben', function(quelle, beamter, daten)
local berichtId = tonumber(daten.id)
if not berichtId then return nil, 'Ungültige Kennung.' end
local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or ''
if #grund < 5 then return nil, 'Bitte gib an, was nachgebessert werden soll.' end
local bericht = LadeBericht(berichtId)
if not bericht then return nil, 'Bericht nicht gefunden.' end
if bericht.status ~= 'eingereicht' then
return nil, 'Nur eingereichte Berichte können zurückgewiesen werden.'
end
MySQL.update.await("UPDATE police_bericht SET status = 'entwurf' WHERE id = ?", { berichtId })
Protokoll.Schreibe(beamter, 'bericht', 'Bericht zurückgewiesen',
{ typ = 'bericht', id = berichtId }, { nummer = bericht.nummer }, grund)
Hinweise.Erstelle({ charakterId = bericht.erstellt_von }, {
kategorie = 'bericht', prioritaet = 'hoch',
titel = 'Bericht zur Nachbesserung',
text = ('%s wurde zurückgewiesen: %s'):format(bericht.nummer, grund),
zielTyp = 'bericht', zielId = berichtId,
})
return { ok = true }
end)
-- =====================================================================
-- HILFSABFRAGEN FÜR DIE OBERFLÄCHE
-- =====================================================================
--- Offene Einsätze und Akten zur Auswahl beim Anlegen
Kern.RegistriereAktion('bericht', 'bezuege', nil, function(quelle, beamter, daten)
local einsaetze = MySQL.query.await([[
SELECT id, nummer, titel FROM police_einsatz
ORDER BY id DESC LIMIT 30
]]) or {}
return { einsaetze = einsaetze }
end)
--- Beweismittel zur Auswahl als Anlage
Kern.RegistriereAktion('bericht', 'beweisSuche', 'bericht.erstellen', function(quelle, beamter, daten)
local begriff = type(daten.begriff) == 'string' and daten.begriff or ''
if #begriff < 2 then return { beweise = {} } end
local muster = '%' .. begriff .. '%'
local beweise = MySQL.query.await([[
SELECT id, kennung, art, bezeichnung, fundort
FROM police_beweis
WHERE kennung LIKE ? OR bezeichnung LIKE ?
ORDER BY id DESC LIMIT 20
]], { muster, muster }) or {}
return { beweise = beweise }
end)