-- ===================================================================== -- aether-medical / Modul: Medikamentenverwaltung -- -- Bestandsführung und Katalogpflege. Nichts weiter. -- -- Das System gibt kein Medikament aus, verordnet keines, bestellt -- nichts nach und entsorgt nichts. Es zählt, was Menschen gebucht -- haben, und erinnert, wenn ein Bestand knapp wird oder ein Datum -- abläuft. Was daraufhin geschieht, entscheidet die Apotheke. -- -- Abgelaufene Medikamente werden AUFFÄLLIG MARKIERT, aber nicht -- gesperrt. Ob ein Präparat noch verwendbar ist, ist eine fachliche -- Beurteilung — keine Datumsrechnung. -- ===================================================================== Kern.RegistriereModul({ id = 'medikament', name = 'Medikamente', icon = '💊', reihenfolge = 8, recht = 'medikament.sehen', beschreibung = 'Bestand, Buchungen, Verfallsdaten und BTM-Buch', }) -- Buchungsarten und ihre Wirkung auf den Bestand local BUCHUNGSARTEN = { eingang = { name = 'Wareneingang', richtung = 1 }, ruecknahme = { name = 'Rücknahme', richtung = 1 }, ausgabe = { name = 'Ausgabe', richtung = -1 }, verfall = { name = 'Verfall entnommen', richtung = -1 }, korrektur = { name = 'Korrektur', richtung = 0 }, } -- --------------------------------------------------------------------- -- 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 --- Name eines Mitarbeiters für die Anzeige 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 Kategorie gegen die Konfiguration local function Kategorie(kennung) for _, eintrag in ipairs(Medizin.Config.Medikamentkategorien) do if eintrag.id == kennung then return eintrag.id end end return 'sonstiges' end -- ===================================================================== -- BESTANDSÜBERSICHT -- ===================================================================== Kern.RegistriereAktion('medikament', 'uebersicht', nil, function(quelle, mitarbeiter, daten) local bedingungen, werte = {}, {} if daten.stillgelegt == true then bedingungen[#bedingungen + 1] = 'm.aktiv = 0' else bedingungen[#bedingungen + 1] = 'm.aktiv = 1' end if daten.kategorie and daten.kategorie ~= '' then bedingungen[#bedingungen + 1] = 'm.kategorie = ?' werte[#werte + 1] = daten.kategorie end -- Schnellfilter der Übersicht local filter = tostring(daten.filter or '') if filter == 'knapp' then bedingungen[#bedingungen + 1] = 'm.bestand <= m.mindestbestand' elseif filter == 'leer' then bedingungen[#bedingungen + 1] = 'm.bestand <= 0' elseif filter == 'verfall' then bedingungen[#bedingungen + 1] = 'm.verfaellt_am IS NOT NULL AND m.verfaellt_am <= DATE_ADD(CURDATE(), INTERVAL ? DAY)' werte[#werte + 1] = Medizin.Config.Erinnerungen.medikamentVorlaufTage elseif filter == 'btm' then bedingungen[#bedingungen + 1] = 'm.betaeubungsmittel = 1' end if type(daten.begriff) == 'string' and #daten.begriff >= 2 then bedingungen[#bedingungen + 1] = '(m.kennung LIKE ? OR m.name LIKE ? OR m.wirkstoff LIKE ?)' local muster = '%' .. daten.begriff .. '%' for _ = 1, 3 do werte[#werte + 1] = muster end end local liste = MySQL.query.await(([[ SELECT m.id, m.kennung, m.name, m.wirkstoff, m.kategorie, m.dosierung_standard, m.einheit, m.bestand, m.mindestbestand, m.lagerort, m.verfaellt_am, m.hinweis, m.betaeubungsmittel, m.aktiv, DATEDIFF(m.verfaellt_am, CURDATE()) AS tageBisVerfall, (SELECT COUNT(*) FROM medical_medikament_buchung b WHERE b.medikament = m.id AND b.art = 'ausgabe' AND b.gebucht_am >= DATE_SUB(NOW(), INTERVAL 7 DAY)) AS ausgabenWoche FROM medical_medikament m WHERE %s ORDER BY m.kategorie, m.sortierung, m.name LIMIT 300 ]]):format(table.concat(bedingungen, ' AND ')), werte) or {} local kategorieNamen = {} for _, eintrag in ipairs(Medizin.Config.Medikamentkategorien) do kategorieNamen[eintrag.id] = eintrag.name end for _, eintrag in ipairs(liste) do eintrag.kategorieName = kategorieNamen[eintrag.kategorie] or eintrag.kategorie eintrag.btm = eintrag.betaeubungsmittel == 1 eintrag.aktiv = eintrag.aktiv == 1 eintrag.knapp = eintrag.bestand <= eintrag.mindestbestand eintrag.leer = eintrag.bestand <= 0 local tage = tonumber(eintrag.tageBisVerfall) eintrag.abgelaufen = tage ~= nil and tage < 0 eintrag.laeuftAb = tage ~= nil and tage >= 0 and tage <= Medizin.Config.Erinnerungen.medikamentVorlaufTage eintrag.betaeubungsmittel = nil end return { liste = liste, kategorien = Medizin.Config.Medikamentkategorien, buchungsarten = BUCHUNGSARTEN, filter = filter, stillgelegt = daten.stillgelegt == true, zaehler = { gesamt = tonumber(MySQL.scalar.await( 'SELECT COUNT(*) FROM medical_medikament WHERE aktiv = 1')) or 0, knapp = tonumber(MySQL.scalar.await([[ SELECT COUNT(*) FROM medical_medikament WHERE aktiv = 1 AND bestand > 0 AND bestand <= mindestbestand ]])) or 0, leer = tonumber(MySQL.scalar.await( 'SELECT COUNT(*) FROM medical_medikament WHERE aktiv = 1 AND bestand <= 0')) or 0, verfall = tonumber(MySQL.scalar.await([[ SELECT COUNT(*) FROM medical_medikament WHERE aktiv = 1 AND verfaellt_am IS NOT NULL AND verfaellt_am <= DATE_ADD(CURDATE(), INTERVAL ? DAY) ]], { Medizin.Config.Erinnerungen.medikamentVorlaufTage })) or 0, btm = tonumber(MySQL.scalar.await( 'SELECT COUNT(*) FROM medical_medikament WHERE aktiv = 1 AND betaeubungsmittel = 1')) or 0, }, rechte = { bestand = Rechte.Hat(mitarbeiter, 'medikament.bestand'), btm = Rechte.Hat(mitarbeiter, 'medikament.btm'), katalog = Rechte.Hat(mitarbeiter, 'katalog.bearbeiten'), }, } end) Kern.RegistriereAktion('medikament', 'oeffnen', nil, function(quelle, mitarbeiter, daten) local medikamentId = tonumber(daten.id) if not medikamentId then return nil, 'Ungültige Kennung.' end local zeilen = MySQL.query.await([[ SELECT m.*, DATEDIFF(m.verfaellt_am, CURDATE()) AS tageBisVerfall FROM medical_medikament m WHERE m.id = ? ]], { medikamentId }) if not zeilen or not zeilen[1] then return nil, 'Medikament nicht gefunden.' end local m = zeilen[1] -- Buchungshistorie local buchungen = MySQL.query.await([[ SELECT b.id, b.art, b.menge, b.bestand_danach, b.grund, b.gebucht_am, b.gebucht_von, b.patient, c.firstname, c.lastname FROM medical_medikament_buchung b LEFT JOIN aether_characters c ON c.id = b.patient WHERE b.medikament = ? ORDER BY b.id DESC LIMIT 60 ]], { medikamentId }) or {} for _, buchung in ipairs(buchungen) do buchung.artName = BUCHUNGSARTEN[buchung.art] and BUCHUNGSARTEN[buchung.art].name or buchung.art buchung.autor = NameVon(buchung.gebucht_von) buchung.patientName = buchung.firstname and (buchung.firstname .. ' ' .. buchung.lastname) or nil buchung.firstname, buchung.lastname, buchung.gebucht_von = nil, nil, nil end -- Zuletzt dokumentierte Gaben local gaben = MySQL.query.await([[ SELECT g.id, g.menge, g.dosierung, g.gegeben_am, g.gegeben_von, g.hinweis, b.kennung AS behandlung, c.firstname, c.lastname FROM medical_behandlung_medikament g JOIN medical_behandlung b ON b.id = g.behandlung LEFT JOIN aether_characters c ON c.id = b.patient WHERE g.medikament = ? ORDER BY g.id DESC LIMIT 30 ]], { medikamentId }) or {} for _, gabe in ipairs(gaben) do gabe.autor = NameVon(gabe.gegeben_von) gabe.patientName = (gabe.firstname or 'Unbekannt') .. ' ' .. (gabe.lastname or '') gabe.ohneBuchung = gabe.hinweis and gabe.hinweis:find('ohne Bestandsbuchung', 1, true) ~= nil or false gabe.firstname, gabe.lastname, gabe.gegeben_von = nil, nil, nil end local tage = tonumber(m.tageBisVerfall) return { medikament = { id = m.id, kennung = m.kennung, name = m.name, wirkstoff = m.wirkstoff, kategorie = m.kategorie, dosierung = m.dosierung_standard, einheit = m.einheit, bestand = m.bestand, mindestbestand = m.mindestbestand, lagerort = m.lagerort, verfaelltAm = m.verfaellt_am, tageBisVerfall = tage, abgelaufen = tage ~= nil and tage < 0, hinweis = m.hinweis, btm = m.betaeubungsmittel == 1, aktiv = m.aktiv == 1, sortierung = m.sortierung, }, buchungen = buchungen, gaben = gaben, kategorien = Medizin.Config.Medikamentkategorien, buchungsarten = BUCHUNGSARTEN, rechte = { bestand = Rechte.Hat(mitarbeiter, 'medikament.bestand'), btm = Rechte.Hat(mitarbeiter, 'medikament.btm'), katalog = Rechte.Hat(mitarbeiter, 'katalog.bearbeiten'), }, } end) -- ===================================================================== -- BESTAND BUCHEN -- -- Jede Buchung ist die Aufzeichnung dessen, was ein Mensch im Schrank -- getan hat. Das System bewegt nichts von selbst. -- ===================================================================== Kern.RegistriereAktion('medikament', 'buchen', 'medikament.bestand', function(quelle, mitarbeiter, daten) local medikamentId = tonumber(daten.id) local art = tostring(daten.art or '') local menge = math.floor(tonumber(daten.menge) or 0) if not medikamentId then return nil, 'Ungültige Kennung.' end if not BUCHUNGSARTEN[art] then return nil, 'Unbekannte Buchungsart.' end if art == 'ausgabe' then return nil, 'Eine Ausgabe entsteht nur aus einer dokumentierten Gabe.' end if menge < 1 or menge > 100000 then return nil, 'Ungültige Menge.' end local grund = Text(daten.grund, 255) if not grund then return nil, 'Bitte einen Grund für die Buchung angeben.' end local zeilen = MySQL.query.await([[ SELECT id, kennung, name, einheit, bestand, mindestbestand, betaeubungsmittel FROM medical_medikament WHERE id = ? ]], { medikamentId }) if not zeilen or not zeilen[1] then return nil, 'Medikament nicht gefunden.' end local medikament = zeilen[1] -- Betäubungsmittel erfordern zusätzlich das eigene Recht if medikament.betaeubungsmittel == 1 and not Rechte.Hat(mitarbeiter, 'medikament.btm') then return nil, 'Betäubungsmittel darfst du nicht buchen.' end local richtung = BUCHUNGSARTEN[art].richtung local neuerBestand if art == 'korrektur' then -- Eine Korrektur setzt den Bestand auf den gezählten Wert neuerBestand = menge else neuerBestand = medikament.bestand + (richtung * menge) if neuerBestand < 0 then return nil, ('Das ergäbe einen Bestand von %d. Vorrätig sind %d %s.') :format(neuerBestand, medikament.bestand, medikament.einheit) end end MySQL.update.await('UPDATE medical_medikament SET bestand = ? WHERE id = ?', { neuerBestand, medikamentId }) MySQL.insert.await([[ INSERT INTO medical_medikament_buchung (medikament, art, menge, bestand_danach, grund, gebucht_von) VALUES (?, ?, ?, ?, ?, ?) ]], { medikamentId, art, menge, neuerBestand, grund, mitarbeiter.charakterId }) Protokoll.Schreibe(mitarbeiter, 'medikament', ('Bestand gebucht: %s'):format(BUCHUNGSARTEN[art].name), { typ = 'medikament', id = medikamentId }, { kennung = medikament.kennung, menge = menge, vorher = medikament.bestand, nachher = neuerBestand, btm = medikament.betaeubungsmittel == 1 }, grund) -- Erinnern, wenn es knapp wird — nachbestellt wird nichts if neuerBestand <= medikament.mindestbestand then Hinweise.Erstelle(nil, { kategorie = 'medikament', prioritaet = neuerBestand <= 0 and 'hoch' or 'normal', titel = neuerBestand <= 0 and 'Medikament nicht mehr vorrätig' or 'Medikament unter Mindestbestand', text = ('%s — noch %d %s.'):format(medikament.name, neuerBestand, medikament.einheit), zielTyp = 'medikament', zielId = medikamentId, }) end Kern.Loese('medikament.gebucht', medikamentId, art, mitarbeiter) return { bestand = neuerBestand } end) -- ===================================================================== -- KATALOG PFLEGEN -- -- Werte hier sind Stammdaten und Vorschläge. Die Standarddosierung -- ist ein Anhaltspunkt, keine Verordnung. -- ===================================================================== Kern.RegistriereAktion('medikament', 'speichern', 'katalog.bearbeiten', function(quelle, mitarbeiter, daten) local kennung = Text(daten.kennung, 24) local name = Text(daten.name, 120) if not kennung or #kennung < 2 then return nil, 'Bitte eine Kennung angeben.' end if not name or #name < 2 then return nil, 'Bitte einen Namen angeben.' end kennung = kennung:gsub('%s', ''):upper() local id = tonumber(daten.id) local btm = daten.btm == true -- Betäubungsmittel darf nur kennzeichnen, wer sie auch buchen darf if btm and not Rechte.Hat(mitarbeiter, 'medikament.btm') then return nil, 'Nur mit BTM-Recht lässt sich ein Präparat als Betäubungsmittel führen.' end local belegt = MySQL.scalar.await( 'SELECT id FROM medical_medikament WHERE kennung = ? AND (? IS NULL OR id <> ?)', { kennung, id, id }) if belegt then return nil, 'Diese Kennung ist bereits vergeben.' end local mindestbestand = math.max(0, math.floor(tonumber(daten.mindestbestand) or 0)) local sortierung = math.floor(tonumber(daten.sortierung) or 0) local verfaellt = Text(daten.verfaelltAm, 10) -- Format JJJJ-MM-TT if verfaellt and not verfaellt:match('^%d%d%d%d%-%d%d%-%d%d$') then return nil, 'Das Verfallsdatum muss im Format JJJJ-MM-TT stehen.' end if id then local alt = MySQL.query.await( 'SELECT kennung, name, mindestbestand, verfaellt_am FROM medical_medikament WHERE id = ?', { id }) if not alt or not alt[1] then return nil, 'Medikament nicht gefunden.' end MySQL.update.await([[ UPDATE medical_medikament SET kennung = ?, name = ?, wirkstoff = ?, kategorie = ?, dosierung_standard = ?, einheit = ?, mindestbestand = ?, lagerort = ?, verfaellt_am = ?, hinweis = ?, betaeubungsmittel = ?, sortierung = ? WHERE id = ? ]], { kennung, name, Text(daten.wirkstoff, 120), Kategorie(daten.kategorie), Text(daten.dosierung, 64), Text(daten.einheit, 24) or 'Stück', mindestbestand, Text(daten.lagerort, 64), verfaellt, Text(daten.hinweis, 255), btm and 1 or 0, sortierung, id, }) Protokoll.Schreibe(mitarbeiter, 'medikament', 'Medikament bearbeitet', { typ = 'medikament', id = id }, { kennung = kennung, verfallAlt = alt[1].verfaellt_am, verfallNeu = verfaellt, mindestAlt = alt[1].mindestbestand, mindestNeu = mindestbestand }) else -- Ein neues Präparat startet mit Bestand 0. Der Zugang wird gebucht. id = MySQL.insert.await([[ INSERT INTO medical_medikament (kennung, name, wirkstoff, kategorie, dosierung_standard, einheit, bestand, mindestbestand, lagerort, verfaellt_am, hinweis, betaeubungsmittel, sortierung) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?) ]], { kennung, name, Text(daten.wirkstoff, 120), Kategorie(daten.kategorie), Text(daten.dosierung, 64), Text(daten.einheit, 24) or 'Stück', mindestbestand, Text(daten.lagerort, 64), verfaellt, Text(daten.hinweis, 255), btm and 1 or 0, sortierung, }) Protokoll.Schreibe(mitarbeiter, 'medikament', 'Medikament angelegt', { typ = 'medikament', id = id }, { kennung = kennung, btm = btm }) end Kern.Loese('medikament.katalog', id, mitarbeiter) return { id = id, kennung = kennung } end) --- Stilllegen statt löschen: alte Gaben bleiben nachvollziehbar Kern.RegistriereAktion('medikament', 'schalten', 'katalog.bearbeiten', function(quelle, mitarbeiter, daten) local medikamentId = tonumber(daten.id) if not medikamentId then return nil, 'Ungültige Kennung.' end local aktiv = daten.aktiv == true local zeilen = MySQL.query.await( 'SELECT id, kennung, name, bestand, einheit FROM medical_medikament WHERE id = ?', { medikamentId }) if not zeilen or not zeilen[1] then return nil, 'Medikament nicht gefunden.' end local medikament = zeilen[1] -- Ein Restbestand wird gemeldet, aber nicht weggebucht if not aktiv and medikament.bestand > 0 then Hinweise.Erstelle(nil, { kategorie = 'medikament', prioritaet = 'normal', titel = 'Stillgelegtes Medikament mit Restbestand', text = ('%s wurde stillgelegt, im Schrank liegen noch %d %s. Die Entnahme muss gebucht werden.') :format(medikament.name, medikament.bestand, medikament.einheit), zielTyp = 'medikament', zielId = medikamentId, }) end MySQL.update.await('UPDATE medical_medikament SET aktiv = ? WHERE id = ?', { aktiv and 1 or 0, medikamentId }) Protokoll.Schreibe(mitarbeiter, 'medikament', aktiv and 'Medikament wieder freigegeben' or 'Medikament stillgelegt', { typ = 'medikament', id = medikamentId }, { kennung = medikament.kennung, restbestand = medikament.bestand }, Text(daten.grund, 255)) return { ok = true } end) -- ===================================================================== -- BTM-BUCH -- -- Lückenlose Bewegungsliste aller Betäubungsmittel. Reine Auskunft; -- hier lässt sich nichts ändern. -- ===================================================================== Kern.RegistriereAktion('medikament', 'btmBuch', 'medikament.btm', function(quelle, mitarbeiter, daten) local medikamentId = tonumber(daten.medikament) local bedingungen = { 'm.betaeubungsmittel = 1' } local werte = {} if medikamentId then bedingungen[#bedingungen + 1] = 'm.id = ?' werte[#werte + 1] = medikamentId end local bewegungen = MySQL.query.await(([[ SELECT b.id, b.art, b.menge, b.bestand_danach, b.grund, b.gebucht_am, b.gebucht_von, b.patient, m.id AS medikamentId, m.kennung, m.name, m.einheit, c.firstname, c.lastname FROM medical_medikament_buchung b JOIN medical_medikament m ON m.id = b.medikament LEFT JOIN aether_characters c ON c.id = b.patient WHERE %s ORDER BY b.id DESC LIMIT 200 ]]):format(table.concat(bedingungen, ' AND ')), werte) or {} for _, eintrag in ipairs(bewegungen) do eintrag.artName = BUCHUNGSARTEN[eintrag.art] and BUCHUNGSARTEN[eintrag.art].name or eintrag.art eintrag.autor = NameVon(eintrag.gebucht_von) eintrag.patientName = eintrag.firstname and (eintrag.firstname .. ' ' .. eintrag.lastname) or nil eintrag.firstname, eintrag.lastname, eintrag.gebucht_von = nil, nil, nil end -- Gaben, die ohne Bestandsbuchung dokumentiert wurden. -- Das ist keine Unregelmässigkeit, sondern ein bewusster Vermerk — -- er gehört aber ins Buch, damit die Zahlen erklärbar bleiben. local ohneBuchung = MySQL.query.await([[ SELECT g.id, g.menge, g.gegeben_am, g.gegeben_von, g.hinweis, m.kennung, m.name, m.einheit, bh.kennung AS behandlung, c.firstname, c.lastname FROM medical_behandlung_medikament g JOIN medical_medikament m ON m.id = g.medikament JOIN medical_behandlung bh ON bh.id = g.behandlung LEFT JOIN aether_characters c ON c.id = bh.patient WHERE m.betaeubungsmittel = 1 AND g.hinweis LIKE '%ohne Bestandsbuchung%' ORDER BY g.id DESC LIMIT 60 ]]) or {} for _, eintrag in ipairs(ohneBuchung) do eintrag.autor = NameVon(eintrag.gegeben_von) eintrag.patientName = (eintrag.firstname or 'Unbekannt') .. ' ' .. (eintrag.lastname or '') eintrag.firstname, eintrag.lastname, eintrag.gegeben_von = nil, nil, nil end -- Aktueller Stand aller Betäubungsmittel local bestaende = MySQL.query.await([[ SELECT id, kennung, name, einheit, bestand, mindestbestand, lagerort, verfaellt_am, DATEDIFF(verfaellt_am, CURDATE()) AS tageBisVerfall FROM medical_medikament WHERE betaeubungsmittel = 1 AND aktiv = 1 ORDER BY name ]]) or {} for _, eintrag in ipairs(bestaende) do local tage = tonumber(eintrag.tageBisVerfall) eintrag.abgelaufen = tage ~= nil and tage < 0 end return { bewegungen = bewegungen, ohneBuchung = ohneBuchung, bestaende = bestaende, gewaehlt = medikamentId, } end) -- --------------------------------------------------------------------- -- Schnittstelle für andere Ressourcen -- --------------------------------------------------------------------- --- Liefert den Bestand eines Medikaments --- @param kennung string Kennung, z. B. 'ANA-01' --- @return table|nil exports('HoleBestand', function(kennung) if type(kennung) ~= 'string' then return nil end local zeilen = MySQL.query.await([[ SELECT kennung, name, einheit, bestand, mindestbestand, verfaellt_am, aktiv FROM medical_medikament WHERE kennung = ? ]], { kennung }) return zeilen and zeilen[1] or nil end) --- Meldet einen Wareneingang aus einer anderen Ressource. --- Der buchende Mitarbeiter muss im Dienst sein — das System bucht nie --- aus eigenem Antrieb. --- @param charakterId number Buchender Mitarbeiter --- @param kennung string Medikament --- @param menge number Anzahl --- @param grund string Begründung --- @return number|nil Neuer Bestand exports('BucheEingang', function(charakterId, kennung, menge, grund) local quelle = Dienst.QuelleVonCharakter(tonumber(charakterId) or 0) local mitarbeiter = quelle and Dienst.Hole(quelle) if not mitarbeiter then return nil end local medikament = MySQL.scalar.await( 'SELECT id FROM medical_medikament WHERE kennung = ?', { tostring(kennung) }) if not medikament then return nil end local ergebnis = Kern.FuehreAus(quelle, mitarbeiter, 'medikament', 'buchen', { id = medikament, art = 'eingang', menge = menge, grund = grund or 'Wareneingang', }) return ergebnis and ergebnis.bestand or nil end)