aether-framework/resources/[aether]/aether-medical/server/hinweise.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

485 lines
22 KiB
Lua

-- =====================================================================
-- aether-medical — Hinweise und Fristenüberwachung
--
-- Dies ist der einzige Teil des Systems, der von selbst tätig wird —
-- und er tut ausschliesslich eines: ERINNERN.
--
-- Kein Hinweis nimmt einen Patienten auf, entlässt ihn, gibt ein
-- Medikament aus, wirft eine Blutkonserve weg oder schliesst eine
-- Dokumentation ab. Jede Frist läuft ab, ohne dass etwas passiert,
-- bis ein Mensch entscheidet.
-- =====================================================================
Hinweise = {}
-- Bereits gemeldete Fristen, damit nicht im Minutentakt gewarnt wird:
-- [schluessel] = { [stufe] = true }
local gemeldet = {}
--- Erstellt einen Hinweis
--- @param ziel table|nil { charakterId? } — ohne charakterId geht er an alle im Dienst
--- @param daten table { kategorie, prioritaet?, titel, text?, zielTyp?, zielId? }
function Hinweise.Erstelle(ziel, daten)
local id = MySQL.insert.await([[
INSERT INTO medical_hinweis
(`character`, kategorie, prioritaet, titel, text, ziel_typ, ziel_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
]], {
ziel and ziel.charakterId or nil,
daten.kategorie or 'system',
daten.prioritaet or 'normal',
daten.titel,
daten.text,
daten.zielTyp,
daten.zielId and tostring(daten.zielId) or nil,
})
-- Betroffene Clients sofort informieren.
-- Die Feldnamen entsprechen bewusst denen aus Hinweise.Lade, damit ein
-- frisch gemeldeter Hinweis genauso anklickbar ist wie ein geladener.
local paket = {
id = id,
kategorie = daten.kategorie or 'system',
prioritaet = daten.prioritaet or 'normal',
titel = daten.titel,
text = daten.text,
zielTyp = daten.zielTyp,
zielId = daten.zielId,
}
if ziel and ziel.charakterId then
local quelle = Dienst.QuelleVonCharakter(ziel.charakterId)
if quelle then
TriggerClientEvent('aether:medical:hinweis', quelle, paket)
end
else
for quelle in pairs(Dienst.AlleImDienst()) do
TriggerClientEvent('aether:medical:hinweis', quelle, paket)
end
end
return id
end
--- Lädt die letzten Hinweise eines Mitarbeiters
--- @param charakterId number Charakter
--- @param limit number|nil Anzahl
--- @return table
function Hinweise.Lade(charakterId, limit)
local zeilen = MySQL.query.await([[
SELECT * FROM medical_hinweis
WHERE (`character` = ? OR `character` IS NULL)
ORDER BY id DESC LIMIT ?
]], { charakterId, limit or 30 }) or {}
-- Sprungziel auf die Schreibweise der Oberfläche bringen
for _, eintrag in ipairs(zeilen) do
eintrag.zielTyp = eintrag.ziel_typ
eintrag.zielId = eintrag.ziel_id
eintrag.ziel_typ, eintrag.ziel_id = nil, nil
end
return zeilen
end
--- Markiert einen Hinweis als gelesen
function Hinweise.MarkiereGelesen(charakterId, hinweisId)
MySQL.update([[
UPDATE medical_hinweis SET gelesen = 1
WHERE id = ? AND (`character` = ? OR `character` IS NULL)
]], { hinweisId, charakterId })
end
--- Meldet eine Frist genau einmal je Stufe
--- @param schluessel string Eindeutige Kennung des Vorgangs
--- @param stufe any Stufe der Erinnerung
--- @return boolean true, wenn jetzt gemeldet werden soll
local function ErstmalsMelden(schluessel, stufe)
gemeldet[schluessel] = gemeldet[schluessel] or {}
if gemeldet[schluessel][stufe] then return false end
gemeldet[schluessel][stufe] = true
return true
end
--- Merkt eine Erinnerung als bereits erledigt vor.
--- Ein Modul, das selbst sofort benachrichtigt, verhindert damit, dass
--- die Fristenprüfung die gleiche Sache kurz darauf noch einmal meldet.
--- @param schluessel string Eindeutige Kennung des Vorgangs
--- @param stufe any Stufe der Erinnerung
function Hinweise.AlsGemeldet(schluessel, stufe)
gemeldet[schluessel] = gemeldet[schluessel] or {}
gemeldet[schluessel][stufe] = true
end
-- ---------------------------------------------------------------------
-- Fristenüberwachung
--
-- Läuft im Hintergrund. Jeder Block hier endet mit einem Hinweis —
-- niemals mit einem UPDATE auf einen medizinischen Vorgang.
-- ---------------------------------------------------------------------
CreateThread(function()
Wait(5000)
while true do
Wait(Medizin.Config.Erinnerungen.pruefIntervall)
-- --- Wartende Patienten ---
local wartend = MySQL.query.await([[
SELECT a.id, a.patient, a.prioritaet, a.grund,
TIMESTAMPDIFF(MINUTE, a.aufgenommen_am, NOW()) AS wartet,
c.firstname, c.lastname
FROM medical_aufnahme a
JOIN aether_characters c ON c.id = a.patient
WHERE a.status = 'wartend'
]]) or {}
for _, eintrag in ipairs(wartend) do
local wartet = tonumber(eintrag.wartet) or 0
local schluessel = 'warte_' .. eintrag.id
-- Höchste erreichte Stufe melden, nicht jede einzeln
local stufe = nil
for _, grenze in ipairs(Medizin.Config.Erinnerungen.warteZeitStufen) do
if wartet >= grenze then stufe = grenze end
end
if stufe and ErstmalsMelden(schluessel, stufe) then
Hinweise.Erstelle(nil, {
kategorie = 'patient',
prioritaet = tonumber(eintrag.prioritaet) <= 2 and 'hoch' or 'normal',
titel = ('Patient wartet seit %d Minuten'):format(stufe),
text = ('%s %s — %s'):format(eintrag.firstname, eintrag.lastname,
tostring(eintrag.grund):sub(1, 80)),
zielTyp = 'aufnahme', zielId = eintrag.id,
})
end
end
-- --- Geplante Operationen ---
local operationen = MySQL.query.await([[
SELECT o.id, o.kennung, o.art, o.patient,
TIMESTAMPDIFF(MINUTE, NOW(), o.geplant_fuer) AS rest,
c.firstname, c.lastname
FROM medical_operation o
JOIN aether_characters c ON c.id = o.patient
WHERE o.status = 'geplant' AND o.geplant_fuer IS NOT NULL
AND o.geplant_fuer > DATE_SUB(NOW(), INTERVAL 2 HOUR)
]]) or {}
for _, operation in ipairs(operationen) do
local rest = tonumber(operation.rest) or 0
local schluessel = 'op_' .. operation.id
for _, vorlauf in ipairs(Medizin.Config.Erinnerungen.opVorlauf) do
if rest <= vorlauf and rest > 0 and ErstmalsMelden(schluessel, vorlauf) then
Hinweise.Erstelle(nil, {
kategorie = 'op', prioritaet = 'normal',
titel = ('Operation beginnt in %d Minuten'):format(vorlauf),
text = ('%s — %s (%s %s)'):format(operation.kennung, operation.art,
operation.firstname, operation.lastname),
zielTyp = 'operation', zielId = operation.id,
})
break
end
end
-- Der geplante Zeitpunkt ist erreicht — die OP startet trotzdem nicht
if rest <= 0 and ErstmalsMelden(schluessel, 'faellig') then
Hinweise.Erstelle(nil, {
kategorie = 'op', prioritaet = 'hoch',
titel = 'Geplante Operation ist fällig',
text = ('%s — %s. Beginn muss vom OP-Team eingetragen werden.')
:format(operation.kennung, operation.art),
zielTyp = 'operation', zielId = operation.id,
})
end
end
-- --- Offene Behandlungsdokumentationen ---
local offeneDoku = MySQL.query.await([[
SELECT b.id, b.kennung, b.erstellt_von, c.firstname, c.lastname
FROM medical_behandlung b
JOIN aether_characters c ON c.id = b.patient
WHERE b.status = 'laufend'
AND b.erstellt_am < DATE_SUB(NOW(), INTERVAL ? MINUTE)
]], { Medizin.Config.Erinnerungen.dokuOffenNach }) or {}
for _, doku in ipairs(offeneDoku) do
if ErstmalsMelden('doku_' .. doku.id, 'offen') then
Hinweise.Erstelle({ charakterId = doku.erstellt_von }, {
kategorie = 'doku', prioritaet = 'normal',
titel = 'Dokumentation fehlt',
text = ('%s (%s %s) ist noch nicht abgeschlossen.')
:format(doku.kennung, doku.firstname, doku.lastname),
zielTyp = 'behandlung', zielId = doku.id,
})
end
end
-- --- Intensivpatienten ohne frischen Verlaufseintrag ---
--
-- Erinnert daran, dass jemand nachschauen sollte. Das System
-- schaut nicht nach, misst nichts und ändert keinen Zustand.
local intensiv = MySQL.query.await([[
SELECT i.id, i.patient, i.zustand, i.verantwortlich,
c.firstname, c.lastname,
TIMESTAMPDIFF(MINUTE, COALESCE(
(SELECT MAX(v.am) FROM medical_intensiv_verlauf v
WHERE v.intensiv = i.id),
i.aufgenommen_am), NOW()) AS stillSeit,
(SELECT COUNT(*) FROM medical_intensiv_verlauf v
WHERE v.intensiv = i.id) AS eintraege
FROM medical_intensiv i
JOIN aether_characters c ON c.id = i.patient
WHERE i.beendet_am IS NULL
]]) or {}
for _, aufenthalt in ipairs(intensiv) do
local still = tonumber(aufenthalt.stillSeit) or 0
-- Die Stufe enthält die Anzahl der Einträge: nach jedem neuen
-- Eintrag beginnt die Erinnerung von vorn.
local stufe = 'still_' .. tostring(aufenthalt.eintraege)
if still >= Medizin.Config.Erinnerungen.intensivStilleNach
and ErstmalsMelden('intensiv_' .. aufenthalt.id, stufe) then
-- Wer zuständig ist, wird persönlich erinnert; sonst das ganze Haus
local ziel = aufenthalt.verantwortlich
and { charakterId = aufenthalt.verantwortlich } or nil
Hinweise.Erstelle(ziel, {
kategorie = 'patient',
prioritaet = aufenthalt.zustand == 'kritisch' and 'hoch' or 'normal',
titel = ('Intensivverlauf seit %d Minuten ohne Eintrag'):format(still),
text = ('%s %s — zuletzt dokumentiert vor %d Minuten.')
:format(aufenthalt.firstname, aufenthalt.lastname, still),
zielTyp = 'intensiv', zielId = aufenthalt.id,
})
end
end
-- --- Laborergebnisse ohne Bewertung ---
local befunde = MySQL.query.await([[
SELECT l.id, l.kennung, l.art, l.auftrag_von, c.firstname, c.lastname
FROM medical_labor l
JOIN aether_characters c ON c.id = l.patient
WHERE l.status = 'fertig' AND l.befund IS NULL
]]) or {}
for _, labor in ipairs(befunde) do
if ErstmalsMelden('labor_' .. labor.id, 'fertig') then
Hinweise.Erstelle({ charakterId = labor.auftrag_von }, {
kategorie = 'labor', prioritaet = 'normal',
titel = 'Laborergebnis verfügbar',
text = ('%s (%s) für %s %s — Bewertung steht aus.')
:format(labor.kennung, labor.art, labor.firstname, labor.lastname),
zielTyp = 'labor', zielId = labor.id,
})
end
end
-- --- Medikamente: Mindestbestand und Verfall ---
local medikamente = MySQL.query.await([[
SELECT id, kennung, name, bestand, mindestbestand, einheit,
DATEDIFF(verfaellt_am, CURDATE()) AS tage
FROM medical_medikament
WHERE aktiv = 1
AND (bestand <= mindestbestand
OR (verfaellt_am IS NOT NULL
AND verfaellt_am <= DATE_ADD(CURDATE(), INTERVAL ? DAY)))
]], { Medizin.Config.Erinnerungen.medikamentVorlaufTage }) or {}
for _, medikament in ipairs(medikamente) do
local schluessel = 'med_' .. medikament.id
if medikament.bestand <= medikament.mindestbestand
and ErstmalsMelden(schluessel, 'bestand') then
Hinweise.Erstelle(nil, {
kategorie = 'medikament',
prioritaet = medikament.bestand <= 0 and 'hoch' or 'normal',
titel = medikament.bestand <= 0 and 'Medikament nicht vorrätig'
or 'Medikament fast leer',
text = ('%s — Bestand %d %s (Mindestbestand %d).')
:format(medikament.name, medikament.bestand,
medikament.einheit, medikament.mindestbestand),
zielTyp = 'medikament', zielId = medikament.id,
})
end
local tage = tonumber(medikament.tage)
if tage and tage <= Medizin.Config.Erinnerungen.medikamentVorlaufTage
and ErstmalsMelden(schluessel, 'verfall') then
Hinweise.Erstelle(nil, {
kategorie = 'medikament', prioritaet = 'normal',
titel = tage <= 0 and 'Medikament abgelaufen' or 'Medikament läuft ab',
text = ('%s — %s. Entscheidung über die Entnahme trifft die Apotheke.')
:format(medikament.name,
tage <= 0 and 'Verfallsdatum überschritten'
or ('noch ' .. tage .. ' Tage')),
zielTyp = 'medikament', zielId = medikament.id,
})
end
end
-- --- Blutkonserven kurz vor Ablauf ---
local konserven = MySQL.query.await([[
SELECT id, konserve, blutgruppe,
DATEDIFF(verfaellt_am, CURDATE()) AS tage
FROM medical_blut
WHERE status IN ('vorraetig', 'reserviert')
AND verfaellt_am IS NOT NULL
AND verfaellt_am <= DATE_ADD(CURDATE(), INTERVAL ? DAY)
]], { Medizin.Config.Erinnerungen.blutVorlaufTage }) or {}
for _, konserve in ipairs(konserven) do
local tage = tonumber(konserve.tage) or 0
if ErstmalsMelden('blut_' .. konserve.id, tage <= 0 and 'abgelaufen' or 'bald') then
Hinweise.Erstelle(nil, {
kategorie = 'blut',
prioritaet = tage <= 0 and 'hoch' or 'normal',
titel = tage <= 0 and 'Blutkonserve abgelaufen' or 'Blutkonserve läuft ab',
text = ('%s (%s) — %s. Verwerfen muss jemand von Hand.')
:format(konserve.konserve, konserve.blutgruppe,
tage <= 0 and 'Ablaufdatum überschritten'
or ('noch ' .. tage .. ' Tage')),
zielTyp = 'blut', zielId = konserve.id,
})
end
end
-- --- Blutgruppen mit knappem Bestand ---
--
-- Erinnert, dass nachbestellt werden sollte. Das System
-- bestellt nichts und gibt nichts frei.
for _, gruppe in ipairs(Medizin.Config.Blutgruppen) do
local vorraetig = tonumber(MySQL.scalar.await([[
SELECT COUNT(*) FROM medical_blut
WHERE blutgruppe = ? AND status = 'vorraetig'
]], { gruppe })) or 0
-- Die Stufe ist der Bestand selbst: jede neue Tiefe meldet sich
-- einmal, ein wieder aufgefüllter Bestand meldet sich nicht.
if vorraetig <= Medizin.Config.Erinnerungen.blutMindestbestand
and ErstmalsMelden('blutbestand_' .. gruppe, tostring(vorraetig)) then
Hinweise.Erstelle(nil, {
kategorie = 'blut',
prioritaet = vorraetig <= 0 and 'hoch' or 'normal',
titel = vorraetig <= 0 and ('Blutgruppe %s nicht vorrätig'):format(gruppe)
or ('Blutgruppe %s wird knapp'):format(gruppe),
text = ('Noch %d Konserve(n) im Bestand (Mindestbestand %d).')
:format(vorraetig, Medizin.Config.Erinnerungen.blutMindestbestand),
})
end
end
-- --- Fahrzeuge mit gemeldeten Auffälligkeiten ---
--
-- Erinnert an das, was der Fahrer gemeldet hat. Kein Hinweis
-- setzt den Status, schickt ein Fahrzeug in die Werkstatt oder
-- nimmt es aus dem Dienst — das trägt ein Mensch ein.
local fahrzeuge = MySQL.query.await([[
SELECT id, kennzeichen, bezeichnung, tank, zustand
FROM medical_fahrzeug
WHERE status IN ('verfuegbar', 'im_einsatz')
AND gemeldet_am IS NOT NULL
AND gemeldet_am > DATE_SUB(NOW(), INTERVAL 1 HOUR)
]]) or {}
for _, fahrzeug in ipairs(fahrzeuge) do
local tank = tonumber(fahrzeug.tank)
local zustand = tonumber(fahrzeug.zustand)
-- Die Stufe ist ein Zehnerband: so meldet sich ein sinkender
-- Wert höchstens zweimal und nicht bei jedem Prozentpunkt.
if tank and tank <= Medizin.Config.Erinnerungen.fahrzeugTankUnter
and ErstmalsMelden('fahrzeug_' .. fahrzeug.id,
'tank_' .. tostring(math.floor(tank / 10))) then
Hinweise.Erstelle(nil, {
kategorie = 'system',
prioritaet = tank <= 5 and 'hoch' or 'normal',
titel = ('Tankstand gemeldet · %s'):format(fahrzeug.bezeichnung),
text = ('%s (%s) — zuletzt %d %% gemeldet.')
:format(fahrzeug.bezeichnung, fahrzeug.kennzeichen, math.floor(tank)),
zielTyp = 'fahrzeug', zielId = fahrzeug.id,
})
end
if zustand and zustand <= Medizin.Config.Erinnerungen.fahrzeugZustandUnter
and ErstmalsMelden('fahrzeug_' .. fahrzeug.id,
'zustand_' .. tostring(math.floor(zustand / 10))) then
Hinweise.Erstelle(nil, {
kategorie = 'system',
prioritaet = zustand <= 25 and 'hoch' or 'normal',
titel = ('Fahrzeugschaden gemeldet · %s'):format(fahrzeug.bezeichnung),
text = ('%s (%s) — Zustand zuletzt %d %%. Ob es in die Werkstatt muss, entscheidet die Wartung.')
:format(fahrzeug.bezeichnung, fahrzeug.kennzeichen, math.floor(zustand)),
zielTyp = 'fahrzeug', zielId = fahrzeug.id,
})
end
end
-- --- Aushänge mit Kenntnisnahmefrist ---
local fristen = MySQL.query.await([[
SELECT a.id, a.titel, a.station,
TIMESTAMPDIFF(MINUTE, NOW(), a.frist) AS rest
FROM medical_aushang a
WHERE a.aktiv = 1 AND a.kenntnispflicht = 1 AND a.frist IS NOT NULL
AND a.frist > DATE_SUB(NOW(), INTERVAL 1 DAY)
]]) or {}
for _, aushang in ipairs(fristen) do
local rest = tonumber(aushang.rest) or 0
local stufe = rest <= 0 and 'abgelaufen'
or (rest <= Medizin.Config.Erinnerungen.aushangVorwarnung and 'bald')
or nil
if stufe and ErstmalsMelden('aushang_' .. aushang.id, stufe) then
-- Nur ansprechen, wer noch nicht bestätigt hat
for _, mitarbeiter in pairs(Dienst.AlleImDienst()) do
local passt = aushang.station == nil or aushang.station == mitarbeiter.station
if passt then
local schon = MySQL.scalar.await([[
SELECT 1 FROM medical_aushang_kenntnis
WHERE aushang = ? AND `character` = ?
]], { aushang.id, mitarbeiter.charakterId })
if not schon then
Hinweise.Erstelle({ charakterId = mitarbeiter.charakterId }, {
kategorie = 'system',
prioritaet = stufe == 'abgelaufen' and 'hoch' or 'normal',
titel = stufe == 'abgelaufen'
and 'Kenntnisnahme überfällig' or 'Kenntnisnahme offen',
text = ('„%s" — bitte zur Kenntnis nehmen.'):format(aushang.titel),
zielTyp = 'aushang', zielId = aushang.id,
})
end
end
end
end
end
end
end)
-- ---------------------------------------------------------------------
-- Aufräumen abgeschlossener Vorgänge
-- ---------------------------------------------------------------------
Kern.Abonniere('aufnahme.entlassen', function(aufnahmeId)
gemeldet['warte_' .. tostring(aufnahmeId)] = nil
end)
Kern.Abonniere('aufnahme.aufgenommen', function(aufnahmeId)
gemeldet['warte_' .. tostring(aufnahmeId)] = nil
end)
Kern.Abonniere('operation.abgeschlossen', function(operationId)
gemeldet['op_' .. tostring(operationId)] = nil
end)
Kern.Abonniere('behandlung.abgeschlossen', function(behandlungId)
gemeldet['doku_' .. tostring(behandlungId)] = nil
end)
Kern.Abonniere('labor.befundet', function(laborId)
gemeldet['labor_' .. tostring(laborId)] = nil
end)