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

541 lines
22 KiB
Lua

-- =====================================================================
-- aether-medical / Modul: Labor
--
-- Das Labor speichert AUSSCHLIESSLICH Ergebnisse.
--
-- Deshalb sind zwei Dinge strikt getrennt:
-- * `ergebnis` — die gemessenen Werte. Trägt das Labor ein.
-- * `befund` — die Bewertung. Trägt ein Mediziner ein.
--
-- Das System vergleicht keine Werte mit Referenzbereichen, markiert
-- nichts als auffällig und leitet aus keinem Ergebnis eine Diagnose
-- ab. Ein Messwert ist eine Zahl, keine Aussage.
--
-- Auch eine im Labor bestimmte Blutgruppe wandert NICHT von selbst in
-- die Stammdaten — das muss ein Mediziner ausdrücklich übernehmen.
-- =====================================================================
Kern.RegistriereModul({
id = 'labor', name = 'Labor', icon = '🧪',
reihenfolge = 6,
-- Ein Recht daraus genügt: Auftraggeber, Labor und Befunder teilen sich das Modul
recht = { 'labor.beauftragen', 'labor.bearbeiten', 'labor.befunden' },
beschreibung = 'Laboraufträge, Ergebnisse und Befunde',
})
-- Schreibhilfen je Probenart. Reine Textvorlagen — sie enthalten keine
-- Referenzbereiche und bewerten nichts, sie geben nur die Struktur vor.
local VORLAGEN = {
blut = 'Hb:\nLeukozyten:\nThrombozyten:\nCRP:\nKreatinin:\nNatrium:\nKalium:\n'
.. 'Blutgruppe:\n\nBemerkung des Labors:',
urin = 'Farbe:\nEiweiß:\nGlukose:\nBlut:\nLeukozyten:\nNitrit:\n\nBemerkung des Labors:',
dna = 'Probenart:\nProfil vollständig (ja/nein):\nÜbereinstimmung:\n\nBemerkung des Labors:',
gewebe = 'Entnahmeort:\nMakroskopisch:\nMikroskopisch:\n\nBemerkung des Labors:',
toxikologie = 'Alkohol:\nOpiate:\nAmphetamine:\nCannabinoide:\nBenzodiazepine:\n'
.. '\nBemerkung des Labors:',
}
-- ---------------------------------------------------------------------
-- Hilfsfunktionen
-- ---------------------------------------------------------------------
--- Begrenzt einen Text und liefert nil bei leerer Eingabe
local function Text(wert, laenge)
if type(wert) ~= 'string' then return nil end
local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '')
if sauber == '' then return nil end
return sauber:sub(1, laenge)
end
--- Prüft eine Probenart gegen die Konfiguration
--- @param kennung any Eingabe
--- @return string|nil
local function Probenart(kennung)
for _, eintrag in ipairs(Medizin.Config.Laborarten) do
if eintrag.id == kennung then return eintrag.id end
end
return nil
end
--- Name eines Mitarbeiters für die Anzeige
local function NameVon(charakterId)
if not charakterId then return 'unbekannt' end
local zeilen = MySQL.query.await([[
SELECT c.firstname, c.lastname FROM aether_characters c WHERE c.id = ?
]], { charakterId })
if not zeilen or not zeilen[1] then return 'unbekannt' end
return zeilen[1].firstname .. ' ' .. zeilen[1].lastname
end
-- =====================================================================
-- ÜBERSICHT
-- =====================================================================
Kern.RegistriereAktion('labor', 'uebersicht', nil, function(quelle, mitarbeiter, daten)
local ordner = tostring(daten.ordner or 'offen')
local bedingungen, werte = {}, {}
if ordner == 'meine' then
bedingungen[#bedingungen + 1] = 'l.auftrag_von = ?'
werte[#werte + 1] = mitarbeiter.charakterId
elseif ordner == 'befund' then
-- Ergebnis liegt vor, die Bewertung fehlt noch
bedingungen[#bedingungen + 1] = "l.status = 'fertig' AND l.befund IS NULL"
elseif ordner == 'archiv' then
bedingungen[#bedingungen + 1] = "(l.befund IS NOT NULL OR l.status = 'verworfen')"
else
bedingungen[#bedingungen + 1] = "l.status IN ('offen', 'in_arbeit')"
end
if daten.art and daten.art ~= '' then
bedingungen[#bedingungen + 1] = 'l.art = ?'
werte[#werte + 1] = daten.art
end
if type(daten.begriff) == 'string' and #daten.begriff >= 2 then
bedingungen[#bedingungen + 1] =
'(l.kennung LIKE ? OR c.firstname LIKE ? OR c.lastname LIKE ?)'
local muster = '%' .. daten.begriff .. '%'
for _ = 1, 3 do werte[#werte + 1] = muster end
end
local liste = MySQL.query.await(([[
SELECT l.id, l.kennung, l.patient, l.art, l.fragestellung, l.dringend,
l.status, l.auftrag_am, l.auftrag_von, l.bearbeitet_am,
l.ergebnis IS NOT NULL AS hatErgebnis,
l.befund IS NOT NULL AS hatBefund,
TIMESTAMPDIFF(MINUTE, l.auftrag_am, NOW()) AS offenSeit,
c.firstname, c.lastname, c.dob
FROM medical_labor l
JOIN aether_characters c ON c.id = l.patient
WHERE %s
ORDER BY l.dringend DESC, l.id DESC
LIMIT 120
]]):format(table.concat(bedingungen, ' AND ')), werte) or {}
local artNamen = {}
for _, eintrag in ipairs(Medizin.Config.Laborarten) do artNamen[eintrag.id] = eintrag.name end
for _, eintrag in ipairs(liste) do
eintrag.patientName = eintrag.firstname .. ' ' .. eintrag.lastname
eintrag.geburt = tostring(eintrag.dob):sub(1, 10)
eintrag.artName = artNamen[eintrag.art] or eintrag.art
eintrag.auftragVon = NameVon(eintrag.auftrag_von)
eintrag.eigene = eintrag.auftrag_von == mitarbeiter.charakterId
eintrag.dringend = eintrag.dringend == 1
eintrag.hatErgebnis = tonumber(eintrag.hatErgebnis) == 1
eintrag.hatBefund = tonumber(eintrag.hatBefund) == 1
eintrag.firstname, eintrag.lastname, eintrag.dob = nil, nil, nil
eintrag.auftrag_von = nil
end
return {
ordner = ordner,
liste = liste,
arten = Medizin.Config.Laborarten,
zaehler = {
offen = tonumber(MySQL.scalar.await(
"SELECT COUNT(*) FROM medical_labor WHERE status IN ('offen', 'in_arbeit')")) or 0,
befund = tonumber(MySQL.scalar.await(
"SELECT COUNT(*) FROM medical_labor WHERE status = 'fertig' AND befund IS NULL")) or 0,
meine = tonumber(MySQL.scalar.await([[
SELECT COUNT(*) FROM medical_labor
WHERE auftrag_von = ? AND status <> 'verworfen'
]], { mitarbeiter.charakterId })) or 0,
dringend = tonumber(MySQL.scalar.await([[
SELECT COUNT(*) FROM medical_labor
WHERE dringend = 1 AND status IN ('offen', 'in_arbeit')
]])) or 0,
},
rechte = {
beauftragen = Rechte.Hat(mitarbeiter, 'labor.beauftragen'),
bearbeiten = Rechte.Hat(mitarbeiter, 'labor.bearbeiten'),
befunden = Rechte.Hat(mitarbeiter, 'labor.befunden'),
stammdaten = Rechte.Hat(mitarbeiter, 'patient.stammdaten'),
},
}
end)
Kern.RegistriereAktion('labor', 'oeffnen', nil, function(quelle, mitarbeiter, daten)
local laborId = tonumber(daten.id)
if not laborId then return nil, 'Ungültige Kennung.' end
local zeilen = MySQL.query.await([[
SELECT l.*, c.firstname, c.lastname, c.dob,
m.blutgruppe, m.allergien, m.hinweis
FROM medical_labor l
JOIN aether_characters c ON c.id = l.patient
LEFT JOIN medical_patient m ON m.`character` = l.patient
WHERE l.id = ?
]], { laborId })
if not zeilen or not zeilen[1] then return nil, 'Laborauftrag nicht gefunden.' end
local l = zeilen[1]
local artName = l.art
for _, eintrag in ipairs(Medizin.Config.Laborarten) do
if eintrag.id == l.art then artName = eintrag.name end
end
-- Frühere Aufträge desselben Patienten zum Vergleich
local frueher = MySQL.query.await([[
SELECT id, kennung, art, status, auftrag_am, ergebnis IS NOT NULL AS hatErgebnis
FROM medical_labor
WHERE patient = ? AND id <> ?
ORDER BY id DESC LIMIT 10
]], { l.patient, laborId }) or {}
for _, eintrag in ipairs(frueher) do
eintrag.hatErgebnis = tonumber(eintrag.hatErgebnis) == 1
end
return {
auftrag = {
id = l.id, kennung = l.kennung, patient = l.patient,
patientName = l.firstname .. ' ' .. l.lastname,
geburt = tostring(l.dob):sub(1, 10),
art = l.art, artName = artName,
fragestellung = l.fragestellung,
dringend = l.dringend == 1,
status = l.status,
auftragAm = l.auftrag_am,
auftragVon = NameVon(l.auftrag_von),
istMeinAuftrag = l.auftrag_von == mitarbeiter.charakterId,
bearbeitetAm = l.bearbeitet_am,
bearbeitetVon = l.bearbeitet_von and NameVon(l.bearbeitet_von) or nil,
ergebnis = l.ergebnis,
befund = l.befund,
befundAm = l.befund_am,
befundVon = l.befund_von and NameVon(l.befund_von) or nil,
},
stamm = {
blutgruppe = l.blutgruppe, allergien = l.allergien, hinweis = l.hinweis,
},
frueher = frueher,
vorlage = VORLAGEN[l.art],
rechte = {
bearbeiten = Rechte.Hat(mitarbeiter, 'labor.bearbeiten'),
befunden = Rechte.Hat(mitarbeiter, 'labor.befunden'),
stammdaten = Rechte.Hat(mitarbeiter, 'patient.stammdaten'),
},
blutgruppen = Medizin.Config.Blutgruppen,
}
end)
-- =====================================================================
-- AUFTRAG STELLEN
-- =====================================================================
Kern.RegistriereAktion('labor', 'patientSuche', 'labor.beauftragen', function(quelle, mitarbeiter, daten)
local begriff = type(daten.begriff) == 'string' and daten.begriff or ''
if #begriff < 2 then return { treffer = {} } end
local muster = '%' .. begriff .. '%'
local treffer = MySQL.query.await([[
SELECT c.id, c.firstname, c.lastname, c.dob, m.blutgruppe, m.hinweis
FROM aether_characters c
LEFT JOIN medical_patient m ON m.`character` = c.id
WHERE c.deleted = 0
AND (c.firstname LIKE ? OR c.lastname LIKE ?
OR CONCAT(c.firstname, ' ', c.lastname) LIKE ? OR c.dob LIKE ?)
ORDER BY c.lastname, c.firstname
LIMIT 20
]], { muster, muster, muster, muster }) or {}
for _, eintrag in ipairs(treffer) do
eintrag.dob = tostring(eintrag.dob):sub(1, 10)
end
return { treffer = treffer }
end)
Kern.RegistriereAktion('labor', 'beauftragen', 'labor.beauftragen', function(quelle, mitarbeiter, daten)
local patientId = tonumber(daten.patient)
if not patientId then return nil, 'Kein Patient gewählt.' end
local person = MySQL.query.await(
'SELECT firstname, lastname FROM aether_characters WHERE id = ? AND deleted = 0',
{ patientId })
if not person or not person[1] then return nil, 'Diese Person ist nicht erfasst.' end
local art = Probenart(daten.art)
if not art then return nil, 'Unbekannte Probenart.' end
local fragestellung = Text(daten.fragestellung, 512)
if not fragestellung then
return nil, 'Bitte angeben, wonach gesucht werden soll.'
end
local dringend = daten.dringend == true
local kennung = Kern.ErzeugeKennung('LAB', 'medical_labor', 'kennung')
local id = MySQL.insert.await([[
INSERT INTO medical_labor
(kennung, patient, art, fragestellung, dringend, status, auftrag_von)
VALUES (?, ?, ?, ?, ?, 'offen', ?)
]], { kennung, patientId, art, fragestellung, dringend and 1 or 0,
mitarbeiter.charakterId })
-- Optionale Verknüpfung mit einer Behandlung
local behandlungId = tonumber(daten.behandlung)
if behandlungId then
local gueltig = MySQL.scalar.await(
'SELECT id FROM medical_behandlung WHERE id = ? AND patient = ?',
{ behandlungId, patientId })
if gueltig then
MySQL.insert.await([[
INSERT INTO medical_verknuepfung (von_typ, von_id, zu_typ, zu_id, von)
VALUES ('labor', ?, 'behandlung', ?, ?)
]], { tostring(id), tostring(behandlungId), mitarbeiter.charakterId })
end
end
Protokoll.Schreibe(mitarbeiter, 'labor', 'Laborauftrag gestellt',
{ typ = 'labor', id = id },
{ kennung = kennung, art = art, patient = patientId, dringend = dringend })
-- Das Labor erfährt davon. Bearbeitet wird nichts von selbst.
Hinweise.Erstelle(nil, {
kategorie = 'labor',
prioritaet = dringend and 'hoch' or 'normal',
titel = dringend and ('Dringender Laborauftrag · %s'):format(kennung)
or ('Neuer Laborauftrag · %s'):format(kennung),
text = ('%s %s — %s'):format(person[1].firstname, person[1].lastname, fragestellung),
zielTyp = 'labor', zielId = id,
})
Kern.Loese('labor.beauftragt', id, mitarbeiter)
return { id = id, kennung = kennung }
end)
-- =====================================================================
-- BEARBEITUNG IM LABOR
-- =====================================================================
Kern.RegistriereAktion('labor', 'annehmen', 'labor.bearbeiten', function(quelle, mitarbeiter, daten)
local laborId = tonumber(daten.id)
if not laborId then return nil, 'Ungültige Kennung.' end
local zeilen = MySQL.query.await(
'SELECT id, kennung, status FROM medical_labor WHERE id = ?', { laborId })
if not zeilen or not zeilen[1] then return nil, 'Laborauftrag nicht gefunden.' end
if zeilen[1].status ~= 'offen' then
return nil, 'Dieser Auftrag ist bereits in Arbeit oder abgeschlossen.'
end
MySQL.update.await([[
UPDATE medical_labor
SET status = 'in_arbeit', bearbeitet_von = ?, bearbeitet_am = NOW()
WHERE id = ?
]], { mitarbeiter.charakterId, laborId })
Protokoll.Schreibe(mitarbeiter, 'labor', 'Auftrag in Arbeit genommen',
{ typ = 'labor', id = laborId }, { kennung = zeilen[1].kennung })
return { ok = true }
end)
--- Trägt die gemessenen Werte ein. Bewertet wird hier nichts.
Kern.RegistriereAktion('labor', 'ergebnis', 'labor.bearbeiten', function(quelle, mitarbeiter, daten)
local laborId = tonumber(daten.id)
if not laborId then return nil, 'Ungültige Kennung.' end
local ergebnis = Text(daten.ergebnis, 8000)
if not ergebnis or #ergebnis < 3 then return nil, 'Bitte ein Ergebnis eintragen.' end
local zeilen = MySQL.query.await([[
SELECT l.id, l.kennung, l.status, l.dringend, l.auftrag_von, l.art,
c.firstname, c.lastname
FROM medical_labor l
JOIN aether_characters c ON c.id = l.patient
WHERE l.id = ?
]], { laborId })
if not zeilen or not zeilen[1] then return nil, 'Laborauftrag nicht gefunden.' end
local auftrag = zeilen[1]
if auftrag.status == 'verworfen' then
return nil, 'Dieser Auftrag wurde verworfen.'
end
MySQL.update.await([[
UPDATE medical_labor
SET ergebnis = ?, status = 'fertig',
bearbeitet_von = COALESCE(bearbeitet_von, ?),
bearbeitet_am = COALESCE(bearbeitet_am, NOW())
WHERE id = ?
]], { ergebnis, mitarbeiter.charakterId, laborId })
Protokoll.Schreibe(mitarbeiter, 'labor', 'Ergebnis eingetragen',
{ typ = 'labor', id = laborId }, { kennung = auftrag.kennung, art = auftrag.art })
-- Den Auftraggeber sofort informieren. Bewertet ist damit nichts.
Hinweise.Erstelle({ charakterId = auftrag.auftrag_von }, {
kategorie = 'labor',
prioritaet = auftrag.dringend == 1 and 'hoch' or 'normal',
titel = ('Laborergebnis verfügbar · %s'):format(auftrag.kennung),
text = ('%s %s — die Bewertung steht noch aus.')
:format(auftrag.firstname, auftrag.lastname),
zielTyp = 'labor', zielId = laborId,
})
-- Die Fristenprüfung soll das nicht gleich noch einmal melden
Hinweise.AlsGemeldet('labor_' .. laborId, 'fertig')
Kern.Loese('labor.fertig', laborId, mitarbeiter)
return { ok = true }
end)
--- Probe unbrauchbar: Auftrag verwerfen, immer mit Begründung
Kern.RegistriereAktion('labor', 'verwerfen', 'labor.bearbeiten', function(quelle, mitarbeiter, daten)
local laborId = tonumber(daten.id)
if not laborId then return nil, 'Ungültige Kennung.' end
local grund = Text(daten.grund, 512)
if not grund then return nil, 'Bitte begründen, warum der Auftrag verworfen wird.' end
local zeilen = MySQL.query.await([[
SELECT l.id, l.kennung, l.status, l.auftrag_von, c.firstname, c.lastname
FROM medical_labor l
JOIN aether_characters c ON c.id = l.patient
WHERE l.id = ?
]], { laborId })
if not zeilen or not zeilen[1] then return nil, 'Laborauftrag nicht gefunden.' end
local auftrag = zeilen[1]
if auftrag.status == 'verworfen' then
return nil, 'Dieser Auftrag ist bereits verworfen.'
end
MySQL.update.await([[
UPDATE medical_labor
SET status = 'verworfen', ergebnis = ?,
bearbeitet_von = COALESCE(bearbeitet_von, ?),
bearbeitet_am = COALESCE(bearbeitet_am, NOW())
WHERE id = ?
]], { 'Verworfen: ' .. grund, mitarbeiter.charakterId, laborId })
Protokoll.Schreibe(mitarbeiter, 'labor', 'Auftrag verworfen',
{ typ = 'labor', id = laborId }, { kennung = auftrag.kennung }, grund)
Hinweise.Erstelle({ charakterId = auftrag.auftrag_von }, {
kategorie = 'labor', prioritaet = 'normal',
titel = ('Laborauftrag verworfen · %s'):format(auftrag.kennung),
text = ('%s %s — %s. Ein neuer Auftrag muss von Hand gestellt werden.')
:format(auftrag.firstname, auftrag.lastname, grund),
zielTyp = 'labor', zielId = laborId,
})
Hinweise.AlsGemeldet('labor_' .. laborId, 'fertig')
return { ok = true }
end)
-- =====================================================================
-- BEFUND
--
-- Die Bewertung eines Ergebnisses. Sie stammt immer von einem
-- Mediziner — das System schreibt hier nie etwas hinein.
-- =====================================================================
Kern.RegistriereAktion('labor', 'befund', 'labor.befunden', function(quelle, mitarbeiter, daten)
local laborId = tonumber(daten.id)
if not laborId then return nil, 'Ungültige Kennung.' end
local befund = Text(daten.befund, 8000)
if not befund or #befund < 3 then return nil, 'Bitte einen Befund eintragen.' end
local zeilen = MySQL.query.await(
'SELECT id, kennung, status, ergebnis FROM medical_labor WHERE id = ?', { laborId })
if not zeilen or not zeilen[1] then return nil, 'Laborauftrag nicht gefunden.' end
local auftrag = zeilen[1]
if not auftrag.ergebnis then
return nil, 'Es liegt noch kein Ergebnis vor, das sich bewerten ließe.'
end
MySQL.update.await([[
UPDATE medical_labor SET befund = ?, befund_von = ?, befund_am = NOW()
WHERE id = ?
]], { befund, mitarbeiter.charakterId, laborId })
Protokoll.Schreibe(mitarbeiter, 'labor', 'Befund erstellt',
{ typ = 'labor', id = laborId }, { kennung = auftrag.kennung })
Kern.Loese('labor.befundet', laborId, mitarbeiter)
return { ok = true }
end)
-- =====================================================================
-- BLUTGRUPPE ÜBERNEHMEN
--
-- Bewusst ein eigener, ausdrücklicher Schritt. Das Labor kann eine
-- Blutgruppe bestimmt haben — in die Stammdaten trägt sie ein
-- Mediziner ein, weil er dafür geradesteht.
-- =====================================================================
Kern.RegistriereAktion('labor', 'blutgruppeUebernehmen', 'patient.stammdaten', function(quelle, mitarbeiter, daten)
local laborId = tonumber(daten.id)
if not laborId then return nil, 'Ungültige Kennung.' end
local zeilen = MySQL.query.await([[
SELECT l.id, l.kennung, l.patient, l.ergebnis, c.firstname, c.lastname
FROM medical_labor l
JOIN aether_characters c ON c.id = l.patient
WHERE l.id = ?
]], { laborId })
if not zeilen or not zeilen[1] then return nil, 'Laborauftrag nicht gefunden.' end
local auftrag = zeilen[1]
if not auftrag.ergebnis then
return nil, 'Zu diesem Auftrag liegt kein Ergebnis vor.'
end
-- Die Blutgruppe wird nicht aus dem Text gelesen, sondern gewählt
local blutgruppe = nil
for _, gruppe in ipairs(Medizin.Config.Blutgruppen) do
if gruppe == daten.blutgruppe then blutgruppe = gruppe break end
end
if not blutgruppe then return nil, 'Bitte eine gültige Blutgruppe wählen.' end
local alt = MySQL.scalar.await(
'SELECT blutgruppe FROM medical_patient WHERE `character` = ?', { auftrag.patient })
MySQL.query.await([[
INSERT INTO medical_patient (`character`, blutgruppe, aktualisiert_von, aktualisiert_am)
VALUES (?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
blutgruppe = VALUES(blutgruppe),
aktualisiert_von = VALUES(aktualisiert_von),
aktualisiert_am = NOW()
]], { auftrag.patient, blutgruppe, mitarbeiter.charakterId })
Protokoll.Schreibe(mitarbeiter, 'labor', 'Blutgruppe in Stammdaten übernommen',
{ typ = 'patient', id = auftrag.patient },
{ quelle = auftrag.kennung, von = alt, nach = blutgruppe })
Kern.Loese('patient.stammdaten', auftrag.patient, mitarbeiter)
return { ok = true, blutgruppe = blutgruppe, vorher = alt }
end)
-- ---------------------------------------------------------------------
-- Schnittstelle für andere Ressourcen
-- ---------------------------------------------------------------------
--- Liefert die Laboraufträge eines Patienten, roh und unbewertet
--- @param patientId number Charakter
--- @return table
exports('HoleLaborwerte', function(patientId)
if not tonumber(patientId) then return {} end
return MySQL.query.await([[
SELECT kennung, art, status, fragestellung, ergebnis, befund,
auftrag_am, befund_am
FROM medical_labor
WHERE patient = ?
ORDER BY id DESC LIMIT 50
]], { tonumber(patientId) }) or {}
end)