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

691 lines
27 KiB
Lua

-- =====================================================================
-- aether-medical / Modul: Blutbank
--
-- Ein Lagerbuch für Blutkonserven. Nichts weiter.
--
-- Das System transfundiert nicht, wählt keine Konserve aus und
-- verwirft keine. Eine abgelaufene Konserve bleibt so lange im
-- Bestand, bis ein Mensch sie herausnimmt — das Ablaufdatum ist
-- eine Erinnerung, keine Sperre.
--
-- Die Verträglichkeitstabelle ist eine Nachschlagehilfe. Passt eine
-- Konserve laut Tabelle nicht zum Empfänger, wird das deutlich
-- angezeigt und mitprotokolliert — verhindert wird es nicht. Ob
-- transfundiert wird, entscheidet ausschliesslich ein Arzt.
-- =====================================================================
Kern.RegistriereModul({
id = 'blutbank', name = 'Blutbank', icon = '🩸',
reihenfolge = 11, recht = 'blut.sehen',
beschreibung = 'Bestand, Reservierungen, Ausgabe und Verfallsdaten',
})
-- Status und ihre erlaubten Übergänge. Jeder Schritt braucht einen
-- Menschen — es gibt keinen Weg, den das System von selbst geht.
local UEBERGAENGE = {
vorraetig = { reserviert = true, ausgegeben = true, verworfen = true },
reserviert = { vorraetig = true, ausgegeben = true, verworfen = true },
ausgegeben = {},
verworfen = {},
}
-- ---------------------------------------------------------------------
-- Hilfsfunktionen
-- ---------------------------------------------------------------------
--- Begrenzt einen Text und liefert nil bei leerer Eingabe
--- @param wert any Eingabe
--- @param laenge number Maximale Zeichenzahl
--- @return string|nil
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
--- Name eines Charakters für die Anzeige
--- @param charakterId number|nil Charakter
--- @return string
local function NameVon(charakterId)
if not charakterId then return 'unbekannt' end
local zeilen = MySQL.query.await(
'SELECT firstname, lastname FROM aether_characters WHERE id = ?', { charakterId })
if not zeilen or not zeilen[1] then return 'unbekannt' end
return zeilen[1].firstname .. ' ' .. zeilen[1].lastname
end
--- Prüft eine Blutgruppe gegen die Konfiguration
--- @param wert any Eingabe
--- @return string|nil Gültige Blutgruppe
local function Blutgruppe(wert)
local gesucht = tostring(wert or '')
for _, gruppe in ipairs(Medizin.Config.Blutgruppen) do
if gruppe == gesucht then return gruppe end
end
return nil
end
--- Prüft einen Lagerort gegen die Konfiguration
--- @param wert any Eingabe
--- @return string|nil Gültiger Lagerort
local function Lagerort(wert)
local gesucht = tostring(wert or '')
for _, ort in ipairs(Medizin.Config.Blutlagerorte) do
if ort == gesucht then return ort end
end
return nil
end
--- Prüft ein Datum im Format JJJJ-MM-TT
--- @param wert any Eingabe
--- @return string|nil
local function Datum(wert)
local roh = Text(wert, 10)
if not roh then return nil end
if not roh:match('^%d%d%d%d%-%d%d%-%d%d$') then return nil end
return roh
end
--- Passt eine Konserve laut Nachschlagetabelle zum Empfänger?
---
--- Liefert `true`, wenn die Kombination in der Tabelle steht, und
--- `nil`, wenn sich dazu nichts sagen lässt — etwa weil die
--- Blutgruppe des Patienten nie bestimmt wurde. Der Rückgabewert
--- sperrt nichts; er wird nur angezeigt und protokolliert.
--- @param empfaenger string|nil Blutgruppe des Patienten
--- @param konserve string Blutgruppe der Konserve
--- @return boolean|nil
local function Vertraeglich(empfaenger, konserve)
if not empfaenger or empfaenger == '' then return nil end
local erlaubt = Medizin.Config.Blutvertraeglichkeit[empfaenger]
if not erlaubt then return nil end
for _, gruppe in ipairs(erlaubt) do
if gruppe == konserve then return true end
end
return false
end
--- Lädt eine Konserve mit den Daten des Empfängers
--- @param konserveId number Konserve
--- @return table|nil
local function LadeKonserve(konserveId)
local zeilen = MySQL.query.await([[
SELECT b.*,
DATEDIFF(b.verfaellt_am, CURDATE()) AS tage,
c.firstname, c.lastname,
p.blutgruppe AS empfaengerGruppe
FROM medical_blut b
LEFT JOIN aether_characters c ON c.id = b.reserviert_fuer
LEFT JOIN medical_patient p ON p.`character` = b.reserviert_fuer
WHERE b.id = ?
]], { konserveId })
return zeilen and zeilen[1] or nil
end
--- Prüft, ob ein Statuswechsel überhaupt vorgesehen ist
--- @param von string Aktueller Status
--- @param nach string Gewünschter Status
--- @return boolean
local function DarfWechseln(von, nach)
return (UEBERGAENGE[von] or {})[nach] == true
end
-- =====================================================================
-- ÜBERSICHT
-- =====================================================================
Kern.RegistriereAktion('blutbank', 'uebersicht', nil, function(quelle, mitarbeiter, daten)
local ordner = tostring(daten.ordner or 'vorraetig')
local vorlauf = Medizin.Config.Erinnerungen.blutVorlaufTage
-- Welche Status gehören in diesen Ordner?
local bedingung, werte
if ordner == 'reserviert' then
bedingung = "b.status = 'reserviert'"
elseif ordner == 'archiv' then
bedingung = "b.status IN ('ausgegeben', 'verworfen')"
else
bedingung = "b.status = 'vorraetig'"
end
werte = {}
if daten.gruppe and daten.gruppe ~= '' then
bedingung = bedingung .. ' AND b.blutgruppe = ?'
werte[#werte + 1] = tostring(daten.gruppe)
end
-- Im Bestand steht die älteste Konserve oben. Das ist eine
-- Sortierung, keine Vorgabe — welche genommen wird, entscheidet
-- weiterhin ein Mensch.
local sortierung = ordner == 'archiv'
and 'b.id DESC'
or 'b.verfaellt_am IS NULL, b.verfaellt_am, b.konserve'
local liste = MySQL.query.await(([[
SELECT b.id, b.konserve, b.blutgruppe, b.menge_ml, b.lagerort, b.status,
b.eingelagert_am, b.eingelagert_von, b.verfaellt_am,
b.reserviert_fuer, b.reserviert_von,
b.ausgegeben_von, b.ausgegeben_am,
DATEDIFF(b.verfaellt_am, CURDATE()) AS tage,
c.firstname, c.lastname,
p.blutgruppe AS empfaengerGruppe
FROM medical_blut b
LEFT JOIN aether_characters c ON c.id = b.reserviert_fuer
LEFT JOIN medical_patient p ON p.`character` = b.reserviert_fuer
WHERE %s
ORDER BY %s
LIMIT 200
]]):format(bedingung, sortierung), werte) or {}
for _, eintrag in ipairs(liste) do
eintrag.tage = eintrag.verfaellt_am and tonumber(eintrag.tage) or nil
-- Abgelaufen heisst hier nur: das Datum ist vorbei. Sonst nichts.
eintrag.abgelaufen = eintrag.tage ~= nil and eintrag.tage <= 0
eintrag.laeuftBaldAb = eintrag.tage ~= nil
and eintrag.tage > 0 and eintrag.tage <= vorlauf
eintrag.empfaenger = eintrag.firstname
and (eintrag.firstname .. ' ' .. eintrag.lastname) or nil
-- Bewusst ohne "and ... or": ein `false` darf hier nicht zu `nil`
-- werden, sonst verschwindet genau die Abweichung, um die es geht.
if eintrag.reserviert_fuer then
eintrag.vertraeglich = Vertraeglich(eintrag.empfaengerGruppe, eintrag.blutgruppe)
end
eintrag.eingelagertVon = NameVon(eintrag.eingelagert_von)
eintrag.reserviertVon = eintrag.reserviert_von
and NameVon(eintrag.reserviert_von) or nil
eintrag.ausgegebenVon = eintrag.ausgegeben_von
and NameVon(eintrag.ausgegeben_von) or nil
eintrag.firstname, eintrag.lastname = nil, nil
eintrag.eingelagert_von, eintrag.reserviert_von, eintrag.ausgegeben_von =
nil, nil, nil
end
-- Bestand je Blutgruppe
local bestand = {}
for _, gruppe in ipairs(Medizin.Config.Blutgruppen) do
local zeilen = MySQL.query.await([[
SELECT status, COUNT(*) AS anzahl
FROM medical_blut
WHERE blutgruppe = ? AND status IN ('vorraetig', 'reserviert')
GROUP BY status
]], { gruppe }) or {}
local eintrag = { gruppe = gruppe, vorraetig = 0, reserviert = 0 }
for _, zeile in ipairs(zeilen) do
eintrag[zeile.status] = tonumber(zeile.anzahl) or 0
end
eintrag.baldAb = tonumber(MySQL.scalar.await([[
SELECT COUNT(*) FROM medical_blut
WHERE blutgruppe = ? AND status IN ('vorraetig', 'reserviert')
AND verfaellt_am IS NOT NULL
AND verfaellt_am <= DATE_ADD(CURDATE(), INTERVAL ? DAY)
]], { gruppe, vorlauf })) or 0
eintrag.knapp = eintrag.vorraetig <= Medizin.Config.Erinnerungen.blutMindestbestand
bestand[#bestand + 1] = eintrag
end
-- Zählwerte über das ganze Lager
local function Zaehle(sql, parameter)
return tonumber(MySQL.scalar.await(sql, parameter)) or 0
end
local zaehler = {
vorraetig = Zaehle("SELECT COUNT(*) FROM medical_blut WHERE status = 'vorraetig'"),
reserviert = Zaehle("SELECT COUNT(*) FROM medical_blut WHERE status = 'reserviert'"),
baldAb = Zaehle([[
SELECT COUNT(*) FROM medical_blut
WHERE status IN ('vorraetig', 'reserviert') AND verfaellt_am IS NOT NULL
AND verfaellt_am <= DATE_ADD(CURDATE(), INTERVAL ? DAY)
AND verfaellt_am >= CURDATE()
]], { vorlauf }),
abgelaufen = Zaehle([[
SELECT COUNT(*) FROM medical_blut
WHERE status IN ('vorraetig', 'reserviert')
AND verfaellt_am IS NOT NULL AND verfaellt_am < CURDATE()
]]),
ausgegebenHeute = Zaehle([[
SELECT COUNT(*) FROM medical_blut
WHERE status = 'ausgegeben' AND DATE(ausgegeben_am) = CURDATE()
]]),
}
return {
ordner = ordner,
liste = liste,
bestand = bestand,
zaehler = zaehler,
gruppen = Medizin.Config.Blutgruppen,
status = Medizin.Config.Blutstatus,
lagerorte = Medizin.Config.Blutlagerorte,
vertraeglichkeit = Medizin.Config.Blutvertraeglichkeit,
vorlaufTage = vorlauf,
mindestbestand = Medizin.Config.Erinnerungen.blutMindestbestand,
rechte = {
verwalten = Rechte.Hat(mitarbeiter, 'blut.verwalten'),
},
}
end)
-- =====================================================================
-- EINZELNE KONSERVE
-- =====================================================================
Kern.RegistriereAktion('blutbank', 'konserve', nil, function(quelle, mitarbeiter, daten)
local konserveId = tonumber(daten.id)
if not konserveId then return nil, 'Ungültige Kennung.' end
local eintrag = LadeKonserve(konserveId)
if not eintrag then return nil, 'Konserve nicht gefunden.' end
local tage = eintrag.verfaellt_am and tonumber(eintrag.tage) or nil
-- Auch hier gilt: `false` ist eine Aussage und darf nicht zu `nil` werden
local passt = nil
if eintrag.reserviert_fuer then
passt = Vertraeglich(eintrag.empfaengerGruppe, eintrag.blutgruppe)
end
-- Der Weg der Konserve steht im Protokoll und wird nur gelesen
local spur = Protokoll.Lies({ zielTyp = 'blut', zielId = konserveId, limit = 50 })
for _, zeile in ipairs(spur) do
zeile.autor = zeile.firstname
and (zeile.firstname .. ' ' .. zeile.lastname) or 'System'
zeile.firstname, zeile.lastname = nil, nil
zeile.details = nil
end
return {
konserve = {
id = eintrag.id, kennung = eintrag.konserve,
blutgruppe = eintrag.blutgruppe, menge = eintrag.menge_ml,
lagerort = eintrag.lagerort, status = eintrag.status,
eingelagertAm = eintrag.eingelagert_am,
eingelagertVon = NameVon(eintrag.eingelagert_von),
verfaelltAm = eintrag.verfaellt_am, tage = tage,
abgelaufen = tage ~= nil and tage <= 0,
empfaenger = eintrag.reserviert_fuer,
empfaengerName = eintrag.firstname
and (eintrag.firstname .. ' ' .. eintrag.lastname) or nil,
empfaengerGruppe = eintrag.empfaengerGruppe,
vertraeglich = passt,
reserviertVon = eintrag.reserviert_von
and NameVon(eintrag.reserviert_von) or nil,
ausgegebenAm = eintrag.ausgegeben_am,
ausgegebenVon = eintrag.ausgegeben_von
and NameVon(eintrag.ausgegeben_von) or nil,
},
spur = spur,
rechte = {
verwalten = Rechte.Hat(mitarbeiter, 'blut.verwalten'),
},
}
end)
-- =====================================================================
-- EINLAGERN
-- =====================================================================
Kern.RegistriereAktion('blutbank', 'einlagern', 'blut.verwalten', function(quelle, mitarbeiter, daten)
local gruppe = Blutgruppe(daten.blutgruppe)
if not gruppe then return nil, 'Unbekannte Blutgruppe.' end
local menge = math.floor(tonumber(daten.menge) or 0)
if menge < 50 or menge > 1000 then
return nil, 'Eine Konserve fasst zwischen 50 und 1000 ml.'
end
local anzahl = math.floor(tonumber(daten.anzahl) or 1)
if anzahl < 1 or anzahl > 20 then
return nil, 'Es lassen sich 1 bis 20 Konserven auf einmal einlagern.'
end
local lagerort = Lagerort(daten.lagerort)
if not lagerort then return nil, 'Unbekannter Lagerort.' end
-- Ein Verfallsdatum ist freiwillig; ohne eines erinnert später nichts
local verfaellt = Datum(daten.verfaellt)
if daten.verfaellt and daten.verfaellt ~= '' and not verfaellt then
return nil, 'Das Verfallsdatum braucht die Form JJJJ-MM-TT.'
end
local angelegt = {}
for _ = 1, anzahl do
local kennung = Kern.ErzeugeKennung('BK', 'medical_blut', 'konserve')
local id = MySQL.insert.await([[
INSERT INTO medical_blut
(konserve, blutgruppe, menge_ml, lagerort, eingelagert_von, verfaellt_am)
VALUES (?, ?, ?, ?, ?, ?)
]], { kennung, gruppe, menge, lagerort, mitarbeiter.charakterId, verfaellt })
angelegt[#angelegt + 1] = { id = id, kennung = kennung }
Protokoll.Schreibe(mitarbeiter, 'blutbank', 'Konserve eingelagert',
{ typ = 'blut', id = id },
{ konserve = kennung, blutgruppe = gruppe, menge = menge,
lagerort = lagerort, verfaellt = verfaellt },
Text(daten.vermerk, 255))
end
Kern.Loese('blut.eingelagert', gruppe, anzahl, mitarbeiter)
return { angelegt = angelegt, anzahl = #angelegt }
end)
-- =====================================================================
-- RESERVIEREN
--
-- Eine Reservierung legt eine Konserve für einen Patienten beiseite.
-- Sie transfundiert nichts und verpflichtet zu nichts.
-- =====================================================================
--- Personensuche für die Reservierung
Kern.RegistriereAktion('blutbank', 'patientSuche', 'blut.verwalten', 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,
p.blutgruppe, p.allergien, p.hinweis,
(SELECT a.status FROM medical_aufnahme a
WHERE a.patient = c.id AND a.status IN ('wartend', 'aufgenommen')
LIMIT 1) AS laufend
FROM aether_characters c
LEFT JOIN medical_patient p ON p.`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('blutbank', 'reservieren', 'blut.verwalten', function(quelle, mitarbeiter, daten)
local konserveId = tonumber(daten.id)
local patientId = tonumber(daten.patient)
if not konserveId then return nil, 'Ungültige Kennung.' end
if not patientId then return nil, 'Kein Patient gewählt.' end
local eintrag = LadeKonserve(konserveId)
if not eintrag then return nil, 'Konserve nicht gefunden.' end
if not DarfWechseln(eintrag.status, 'reserviert') then
return nil, ('Eine Konserve im Status "%s" lässt sich nicht reservieren.')
:format(eintrag.status)
end
local person = MySQL.query.await([[
SELECT c.firstname, c.lastname, p.blutgruppe
FROM aether_characters c
LEFT JOIN medical_patient p ON p.`character` = c.id
WHERE c.id = ? AND c.deleted = 0
]], { patientId })
if not person or not person[1] then return nil, 'Diese Person ist nicht erfasst.' end
-- Die Tabelle sagt etwas dazu — entschieden wird trotzdem hier oben
local passt = Vertraeglich(person[1].blutgruppe, eintrag.blutgruppe)
MySQL.update.await([[
UPDATE medical_blut
SET status = 'reserviert', reserviert_fuer = ?, reserviert_von = ?
WHERE id = ?
]], { patientId, mitarbeiter.charakterId, konserveId })
Protokoll.Schreibe(mitarbeiter, 'blutbank', 'Konserve reserviert',
{ typ = 'blut', id = konserveId },
{ konserve = eintrag.konserve, blutgruppe = eintrag.blutgruppe,
patient = patientId, patientGruppe = person[1].blutgruppe,
lautTabelleVertraeglich = passt },
Text(daten.vermerk, 255))
-- Eine Abweichung von der Tabelle wird deutlich gemeldet, nicht verhindert
if passt == false then
Hinweise.Erstelle(nil, {
kategorie = 'blut', prioritaet = 'hoch',
titel = 'Reservierung weicht von der Verträglichkeitstabelle ab',
text = ('%s (%s) für %s %s mit Blutgruppe %s — bitte prüfen.')
:format(eintrag.konserve, eintrag.blutgruppe,
person[1].firstname, person[1].lastname, person[1].blutgruppe),
zielTyp = 'blut', zielId = konserveId,
})
end
Kern.Loese('blut.reserviert', konserveId, patientId, mitarbeiter)
return { ok = true, vertraeglich = passt }
end)
Kern.RegistriereAktion('blutbank', 'reservierungLoesen', 'blut.verwalten', function(quelle, mitarbeiter, daten)
local konserveId = tonumber(daten.id)
if not konserveId then return nil, 'Ungültige Kennung.' end
local grund = Text(daten.grund, 255)
if not grund then return nil, 'Bitte angeben, warum die Reservierung entfällt.' end
local eintrag = LadeKonserve(konserveId)
if not eintrag then return nil, 'Konserve nicht gefunden.' end
if eintrag.status ~= 'reserviert' then
return nil, 'Diese Konserve ist nicht reserviert.'
end
MySQL.update.await([[
UPDATE medical_blut
SET status = 'vorraetig', reserviert_fuer = NULL, reserviert_von = NULL
WHERE id = ?
]], { konserveId })
Protokoll.Schreibe(mitarbeiter, 'blutbank', 'Reservierung aufgehoben',
{ typ = 'blut', id = konserveId },
{ konserve = eintrag.konserve, patient = eintrag.reserviert_fuer }, grund)
Kern.Loese('blut.freigegeben', konserveId, mitarbeiter)
return { ok = true }
end)
-- =====================================================================
-- AUSGABE
--
-- Dokumentiert, dass eine Konserve das Lager verlassen hat. Was mit
-- ihr geschieht, entscheidet und verantwortet der Arzt am Patienten.
-- =====================================================================
Kern.RegistriereAktion('blutbank', 'ausgeben', 'blut.verwalten', function(quelle, mitarbeiter, daten)
local konserveId = tonumber(daten.id)
if not konserveId then return nil, 'Ungültige Kennung.' end
local eintrag = LadeKonserve(konserveId)
if not eintrag then return nil, 'Konserve nicht gefunden.' end
if not DarfWechseln(eintrag.status, 'ausgegeben') then
return nil, ('Eine Konserve im Status "%s" lässt sich nicht ausgeben.')
:format(eintrag.status)
end
-- Ohne Reservierung muss bei der Ausgabe gesagt werden, für wen
local patientId = tonumber(daten.patient) or eintrag.reserviert_fuer
if not patientId then return nil, 'Bitte angeben, für welchen Patienten.' end
local vermerk = Text(daten.vermerk, 255)
if not vermerk then return nil, 'Bitte einen Vermerk zur Ausgabe hinterlassen.' end
local person = MySQL.query.await([[
SELECT c.firstname, c.lastname, p.blutgruppe
FROM aether_characters c
LEFT JOIN medical_patient p ON p.`character` = c.id
WHERE c.id = ? AND c.deleted = 0
]], { patientId })
if not person or not person[1] then return nil, 'Diese Person ist nicht erfasst.' end
local passt = Vertraeglich(person[1].blutgruppe, eintrag.blutgruppe)
local tage = eintrag.verfaellt_am and tonumber(eintrag.tage) or nil
local abgelaufen = tage ~= nil and tage <= 0
MySQL.update.await([[
UPDATE medical_blut
SET status = 'ausgegeben', reserviert_fuer = ?,
ausgegeben_von = ?, ausgegeben_am = NOW()
WHERE id = ?
]], { patientId, mitarbeiter.charakterId, konserveId })
Protokoll.Schreibe(mitarbeiter, 'blutbank', 'Konserve ausgegeben',
{ typ = 'blut', id = konserveId },
{ konserve = eintrag.konserve, blutgruppe = eintrag.blutgruppe,
patient = patientId, patientGruppe = person[1].blutgruppe,
lautTabelleVertraeglich = passt, abgelaufen = abgelaufen }, vermerk)
if passt == false then
Hinweise.Erstelle(nil, {
kategorie = 'blut', prioritaet = 'kritisch',
titel = 'Ausgabe weicht von der Verträglichkeitstabelle ab',
text = ('%s (%s) an %s %s mit Blutgruppe %s — ausgegeben von %s.')
:format(eintrag.konserve, eintrag.blutgruppe,
person[1].firstname, person[1].lastname,
person[1].blutgruppe, mitarbeiter.name),
zielTyp = 'blut', zielId = konserveId,
})
end
if abgelaufen then
Hinweise.Erstelle(nil, {
kategorie = 'blut', prioritaet = 'hoch',
titel = 'Abgelaufene Konserve ausgegeben',
text = ('%s war seit %d Tagen über dem Datum — ausgegeben von %s.')
:format(eintrag.konserve, math.abs(tage), mitarbeiter.name),
zielTyp = 'blut', zielId = konserveId,
})
end
Kern.Loese('blut.ausgegeben', konserveId, patientId, mitarbeiter)
return { ok = true, vertraeglich = passt, abgelaufen = abgelaufen }
end)
-- =====================================================================
-- VERWERFEN
--
-- Auch abgelaufene Konserven verschwinden nicht von selbst. Ob eine
-- Konserve noch brauchbar ist, ist eine fachliche Beurteilung —
-- keine Datumsrechnung.
-- =====================================================================
Kern.RegistriereAktion('blutbank', 'verwerfen', 'blut.verwalten', function(quelle, mitarbeiter, daten)
local konserveId = tonumber(daten.id)
if not konserveId then return nil, 'Ungültige Kennung.' end
local grund = Text(daten.grund, 255)
if not grund or #grund < 3 then
return nil, 'Das Verwerfen einer Konserve erfordert eine Begründung.'
end
local eintrag = LadeKonserve(konserveId)
if not eintrag then return nil, 'Konserve nicht gefunden.' end
if not DarfWechseln(eintrag.status, 'verworfen') then
return nil, ('Eine Konserve im Status "%s" lässt sich nicht verwerfen.')
:format(eintrag.status)
end
local warReserviert = eintrag.status == 'reserviert'
MySQL.update.await("UPDATE medical_blut SET status = 'verworfen' WHERE id = ?",
{ konserveId })
Protokoll.Schreibe(mitarbeiter, 'blutbank', 'Konserve verworfen',
{ typ = 'blut', id = konserveId },
{ konserve = eintrag.konserve, blutgruppe = eintrag.blutgruppe,
vorherigerStatus = eintrag.status,
verfaellt = eintrag.verfaellt_am }, grund)
-- Eine verworfene Reservierung darf niemandem entgehen
if warReserviert and eintrag.reserviert_fuer then
Hinweise.Erstelle(nil, {
kategorie = 'blut', prioritaet = 'hoch',
titel = 'Reservierte Konserve verworfen',
text = ('%s war für %s %s zurückgelegt.')
:format(eintrag.konserve, eintrag.firstname or '', eintrag.lastname or ''),
zielTyp = 'blut', zielId = konserveId,
})
end
Kern.Loese('blut.verworfen', konserveId, mitarbeiter)
return { ok = true }
end)
-- =====================================================================
-- LAGERORT
-- =====================================================================
Kern.RegistriereAktion('blutbank', 'umlagern', 'blut.verwalten', function(quelle, mitarbeiter, daten)
local konserveId = tonumber(daten.id)
if not konserveId then return nil, 'Ungültige Kennung.' end
local lagerort = Lagerort(daten.lagerort)
if not lagerort then return nil, 'Unbekannter Lagerort.' end
local eintrag = LadeKonserve(konserveId)
if not eintrag then return nil, 'Konserve nicht gefunden.' end
if eintrag.status ~= 'vorraetig' and eintrag.status ~= 'reserviert' then
return nil, 'Diese Konserve liegt nicht mehr im Lager.'
end
MySQL.update.await('UPDATE medical_blut SET lagerort = ? WHERE id = ?',
{ lagerort, konserveId })
Protokoll.Schreibe(mitarbeiter, 'blutbank', 'Konserve umgelagert',
{ typ = 'blut', id = konserveId },
{ konserve = eintrag.konserve, von = eintrag.lagerort, nach = lagerort },
Text(daten.vermerk, 255))
return { ok = true }
end)
-- ---------------------------------------------------------------------
-- Schnittstelle für andere Ressourcen
-- ---------------------------------------------------------------------
--- Liefert den Bestand je Blutgruppe.
--- Reine Auskunft — keine Ressource kann darüber eine Konserve
--- reservieren, ausgeben oder verwerfen.
--- @return table Liste { blutgruppe, vorraetig, reserviert }
exports('HoleBlutbestand', function()
local liste = {}
for _, gruppe in ipairs(Medizin.Config.Blutgruppen) do
liste[#liste + 1] = {
blutgruppe = gruppe,
vorraetig = tonumber(MySQL.scalar.await([[
SELECT COUNT(*) FROM medical_blut
WHERE blutgruppe = ? AND status = 'vorraetig'
]], { gruppe })) or 0,
reserviert = tonumber(MySQL.scalar.await([[
SELECT COUNT(*) FROM medical_blut
WHERE blutgruppe = ? AND status = 'reserviert'
]], { gruppe })) or 0,
}
end
return liste
end)