This commit is contained in:
Dwi Swandhana
2026-02-21 18:58:12 +07:00
parent aed5885eb1
commit af2dd2f819
3 changed files with 297 additions and 29 deletions
@@ -122,9 +122,12 @@ class BiorepositoryController extends Controller
'slotnomor' => 'required|integer|min:1',
'boxnomor' => 'required|integer|min:1',
'tubenomor' => 'required|integer|min:1',
'nmbakteri' => 'required|max:200',
'strain' => 'required|in:Gram Negatif,Gram Positif',
'nmbakteri' => 'nullable|max:200',
'strain' => 'nullable|in:Gram Negatif,Gram Positif',
'stored_at' => 'required|date',
'volume' => 'nullable|numeric|min:0',
'volume_ambil' => 'nullable|numeric|min:0',
'existing_specimen_id' => 'nullable|integer',
]);
if ($validator->fails()) {
@@ -151,10 +154,36 @@ class BiorepositoryController extends Controller
$slotTaken = BioSpecimen::where('rack_id', $rack->id)
->where('shelf_number', $request->input('shelfnomor'))
->where('tube_number', $request->input('tubenomor'))
->exists();
->first();
if ($slotTaken) {
return back()->withErrors(['tubenomor' => 'Posisi tube pada shelf ini sudah terisi spesimen.'])->withInput();
$existingId = (int) $request->input('existing_specimen_id');
if ($existingId !== (int) $slotTaken->id) {
return back()->withErrors(['tubenomor' => 'Posisi tube pada shelf ini sudah terisi spesimen.'])->withInput();
}
$volumeAmbil = (float) $request->input('volume_ambil');
if ($volumeAmbil <= 0) {
return back()->withErrors(['volume_ambil' => 'Isi volume yang diambil dari spesimen.'])->withInput();
}
$volumeSekarang = (float) ($slotTaken->volume ?? 0);
if ($volumeAmbil > $volumeSekarang) {
return back()->withErrors(['volume_ambil' => 'Volume diambil melebihi sisa volume spesimen.'])->withInput();
}
$slotTaken->volume = (string) ($volumeSekarang - $volumeAmbil);
$slotTaken->save();
return redirect('/biorepository')->with('success', 'Pengambilan volume berhasil. Sisa volume diperbarui.');
}
if (!$request->filled('nmbakteri') || !$request->filled('strain')) {
return back()->withErrors(['nmbakteri' => 'Nama bakteri dan strain wajib diisi untuk spesimen baru.'])->withInput();
}
if (!$request->filled('volume')) {
return back()->withErrors(['volume' => 'Volume awal spesimen wajib diisi.'])->withInput();
}
$atccByUser = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', Session::get('nama', 'UNKNOWN')));
@@ -195,6 +224,7 @@ class BiorepositoryController extends Controller
'input_by' => Session::get('nama'),
'stored_at' => $request->input('stored_at'),
'storage_condition' => $this->mapStorageCondition($request->input('kategorisimpan')),
'volume' => (string) $request->input('volume'),
'notes' => $request->input('notes'),
]);
@@ -63,7 +63,7 @@
background: #fce8e8;
border-color: #e7adad;
color: #8d1f1f;
cursor: not-allowed;
cursor: pointer;
}
.oldest-highlight {
border: 1px solid #ffd58f;
@@ -206,9 +206,23 @@
@for($tube = 1; $tube <= $tubeCapacity; $tube++)
@php $tubeKey = $shelf.'-'.$tube; @endphp
@if(isset($tubeMap[$tubeKey]))
<div class="slot-btn slot-filled" style="font-size:7px; line-height:1.1; padding:4px 2px;" title="{{ $tubeMap[$tubeKey]->specimen_code }} - {{ $tubeMap[$tubeKey]->bacteria_name ?? $tubeMap[$tubeKey]->specimen_name }} / {{ $tubeMap[$tubeKey]->strain }}">
{{ ($tubeMap[$tubeKey]->bacteria_name ?? $tubeMap[$tubeKey]->specimen_name) }} / {{ $tubeMap[$tubeKey]->strain ?? '-' }}
</div>
<button type="button"
class="slot-btn slot-filled js-slot-filled"
style="font-size:7px; line-height:1.1; padding:4px 2px;"
data-specimen-id="{{ $tubeMap[$tubeKey]->id }}"
data-rack-id="{{ $rack->id }}"
data-shelf="{{ $shelf }}"
data-rackno="{{ $rack->rack_number ?? $rack->id }}"
data-slot="{{ $shelf }}"
data-box="1"
data-tube="{{ $tube }}"
data-bakteri="{{ $tubeMap[$tubeKey]->bacteria_name ?? $tubeMap[$tubeKey]->specimen_name }}"
data-strain="{{ $tubeMap[$tubeKey]->strain }}"
data-volume="{{ $tubeMap[$tubeKey]->volume ?? 0 }}"
title="{{ $tubeMap[$tubeKey]->specimen_code }} - {{ $tubeMap[$tubeKey]->bacteria_name ?? $tubeMap[$tubeKey]->specimen_name }} / {{ $tubeMap[$tubeKey]->strain }}">
{{ ($tubeMap[$tubeKey]->bacteria_name ?? $tubeMap[$tubeKey]->specimen_name) }} / {{ $tubeMap[$tubeKey]->strain ?? '-' }}<br />
{{ $tubeMap[$tubeKey]->volume ?? 0 }} ml
</button>
@else
<button type="button"
class="slot-btn js-slot"
@@ -409,6 +423,7 @@
@csrf
<div class="modal-body">
<input type="hidden" id="rack_id" name="rack_id">
<input type="hidden" id="existing_specimen_id" name="existing_specimen_id">
<div class="form-group m-b-25">
<div class="col-12">
@@ -466,6 +481,21 @@
</div>
</div>
<div class="form-group m-b-25" id="group-volume-awal">
<div class="col-12">
<label>Volume Awal (ml)</label>
<input type="number" step="0.01" min="0" class="form-control" id="volume" name="volume">
</div>
</div>
<div class="form-group m-b-25" id="group-volume-ambil" style="display:none;">
<div class="col-12">
<label>Volume Diambil (ml)</label>
<input type="number" step="0.01" min="0" class="form-control" id="volume_ambil" name="volume_ambil">
<small class="text-muted">Sisa volume saat ini: <span id="volume_sekarang_text">0</span> ml</small>
</div>
</div>
<div class="form-group m-b-0">
<div class="col-12">
<label>Sample Code (Preview Otomatis)</label>
@@ -497,12 +527,39 @@
}
$(document).on('click', '.js-slot', function () {
$('#existing_specimen_id').val('');
$('#rack_id').val($(this).data('rack-id'));
$('#shelfnomor').val($(this).data('shelf'));
$('#raknomor').val($(this).data('rackno'));
$('#slotnomor').val($(this).data('slot'));
$('#boxnomor').val($(this).data('box'));
$('#tubenomor').val($(this).data('tube'));
$('#nmbakteri').val('').prop('readonly', false);
$('#strain').val('Gram Negatif').prop('disabled', false);
$('#volume').val('');
$('#volume_ambil').val('');
$('#group-volume-awal').show();
$('#group-volume-ambil').hide();
$('#volume_sekarang_text').text('0');
buildSampleCodePreview();
$('#modalIsiSlot').modal('show');
});
$(document).on('click', '.js-slot-filled', function () {
$('#existing_specimen_id').val($(this).data('specimen-id'));
$('#rack_id').val($(this).data('rack-id'));
$('#shelfnomor').val($(this).data('shelf'));
$('#raknomor').val($(this).data('rackno'));
$('#slotnomor').val($(this).data('slot'));
$('#boxnomor').val($(this).data('box'));
$('#tubenomor').val($(this).data('tube'));
$('#nmbakteri').val($(this).data('bakteri')).prop('readonly', true);
$('#strain').val($(this).data('strain')).prop('disabled', true);
$('#volume').val('');
$('#volume_ambil').val('');
$('#group-volume-awal').hide();
$('#group-volume-ambil').show();
$('#volume_sekarang_text').text($(this).data('volume'));
buildSampleCodePreview();
$('#modalIsiSlot').modal('show');
});
+202 -21
View File
@@ -17,11 +17,28 @@ from sqlalchemy import create_engine, Column, Integer, String, Boolean, Text # t
from sqlalchemy import DateTime as SqDateTime # type: ignore
from sqlalchemy import Date as SqDate # type: ignore
from sqlalchemy.orm import declarative_base, sessionmaker # type: ignore
# Logging Setup
# Konfigurasi logging per hari
log_handler = TimedRotatingFileHandler(
filename="app.log",
when="midnight",
interval=1,
backupCount=7,
encoding="utf-8"
)
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(threadName)s - %(message)s')
log_handler.setFormatter(formatter)
logging.basicConfig(level=logging.INFO, handlers=[log_handler])
# ==========================================
# 1. KONFIGURASI SISTEM
# ==========================================
# Global Variables
app = Flask(__name__)
active_genexpert_connections = {}
connection_lock = threading.Lock()
pending_result_queries = {}
pending_query_lock = threading.Lock()
# Network Configuration
TCP_LISTENER_PORT = 6001 # PC GeneXpert set ke mode Client, konek ke IP:PORT ini
SERVER_HOST = '0.0.0.0' # Listen di semua interface
@@ -61,25 +78,6 @@ GENEXPERT_IP_CAPABILITIES = {
# Default code jika nama tes di database tidak dikenali
DEFAULT_GXP_CODE = "MTB-RIF"
# Logging Setup
# Konfigurasi logging per hari
log_handler = TimedRotatingFileHandler(
filename="app.log",
when="midnight",
interval=1,
backupCount=7,
encoding="utf-8"
)
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(threadName)s - %(message)s')
log_handler.setFormatter(formatter)
logging.basicConfig(level=logging.INFO, handlers=[log_handler])
# Global Variables
app = Flask(__name__)
active_genexpert_connections = {}
connection_lock = threading.Lock()
pending_result_queries = {}
pending_query_lock = threading.Lock()
DEVICE_CONFIGS = [
{
'port': 'COM6', 'baud_rate': 9600, 'device_type': 'vitek', 'alat_name': 'Vitek 1',
@@ -99,6 +97,8 @@ DEVICE_CONFIGS = [
# 'protocol': 'serial', 'flag_column': 'flg_bd2'
#},
]
MYLA_HOST = '0.0.0.0'
MYLA_PORT = 60090
# Karakter kontrol standar
STX, ETX, ACK, NAK, EOT, ENQ = b'\x02', b'\x03', b'\x06', b'\x15', b'\x04', b'\x05'
@@ -556,10 +556,186 @@ def create_hl7_dsr_response(order, msg_control_id, qrd_segment):
# Kita kirim QAK (Query Acknowledge) dengan status NF (Not Found) di QRF/QAK
# Atau cukup kirim MSA|AA tapi tanpa segmen Order
return f"{msh}\r{msa}\r{qrd}\r{qrf}\r"
def parse_myla_result(hl7_message, device_name="MYLA"):
"""
Parser khusus untuk HL7 dari bioMérieux MYLA.
Menangani hasil BACT/ALERT (Pos/Neg) dan VITEK (Organisme & AST/Sensitivitas).
"""
session = SessionLocal()
try:
segments = hl7_message.strip().split('\r')
sample_id = None
patient_id = ""
patient_name = ""
result_date = datetime.datetime.now()
# Penampung Hasil
kultur_id = [] # Untuk hasil organisme / Positif/Negatif
ast_results = [] # Untuk hasil sensitivitas antibiotik
for segment in segments:
fields = segment.split('|')
if not fields: continue
seg_type = fields[0]
if seg_type == 'MSH':
if len(fields) > 6 and fields[6]:
try:
result_date = datetime.datetime.strptime(fields[6][:14], "%Y%m%d%H%M%S")
except: pass
elif seg_type == 'PID':
if len(fields) > 3: patient_id = fields[3].replace('^', '')
if len(fields) > 5: patient_name = fields[5].replace('^', ' ').strip()
elif seg_type == 'OBR':
# OBR-3 (Filler Order Number) atau OBR-2 (Placer) biasanya berisi Sample ID
if len(fields) > 3 and fields[3]:
sample_id = fields[3].replace('^', '')
elif len(fields) > 2 and fields[2]:
sample_id = fields[2].replace('^', '')
elif seg_type == 'OBX':
# Parsing detail OBX
# OBX-3: Parameter (Misal: Organism / Nama Antibiotik)
# OBX-5: Nilai Hasil / MIC
# OBX-8: Interpretasi (S / I / R / + / -)
if len(fields) > 5:
test_param = fields[3].split('^')[1] if '^' in fields[3] else fields[3]
result_val = fields[5].replace('^', ' ').strip()
interpretation = ""
if len(fields) > 8 and fields[8]:
interpretation = fields[8].strip()
# Logika Pemisahan (Tergantung mapping kata kunci MYLA)
# Biasanya VITEK mengirim interpretasi 'S', 'I', 'R' untuk antibiotik
if interpretation in ['S', 'I', 'R']:
ast_results.append(f"{test_param}: {result_val} ({interpretation})")
else:
# Ini kemungkinan hasil Kultur / Identifikasi Organisme
kultur_id.append(f"{test_param}: {result_val}")
# --- GABUNGKAN HASIL ---
final_results = []
if kultur_id:
final_results.append("ID: " + ", ".join(kultur_id))
if ast_results:
final_results.append("AST: " + "; ".join(ast_results))
final_result_str = " | ".join(final_results) if final_results else "No Data"
# --- SIMPAN KE DB ---
if not sample_id:
# Fallback jika tidak ada ID
sample_id = f"ERR_MYLA_{datetime.datetime.now().strftime('%H%M%S')}"
final_result_str = f"[NO_ID] {final_result_str}"
logging.info(f"[MYLA] Save DB -> Sample: {sample_id}, Hasil: {final_result_str}")
new_entry = LisPhoenix(
no_id=sample_id,
seq_no=patient_id,
rnmpas=patient_name,
tgl_data=result_date,
rawdt=hl7_message,
organisme=final_result_str[:255], # Potong jika field database terbatas
alat=device_name
)
session.add(new_entry)
session.commit()
except Exception as e:
logging.error(f"[MYLA Parser] Error: {e}")
session.rollback()
finally:
session.close()
# ==========================================
# 4. NETWORK & COMMUNICATION LOGIC
# ==========================================
def handle_myla_client(conn, addr):
"""
TCP Handler untuk bioMérieux MYLA.
Hanya menerima HL7 berbungkus MLLP (\x0b ... \x1c\r).
"""
logging.info(f"[MYLA-TCP] Koneksi baru dari {addr}")
buffer = b""
try:
while True:
data = conn.recv(4096)
if not data: break
buffer += data
# Cek penutup MLLP (\x1c\r)
if b'\x1c\r' in buffer:
# Pisahkan pesan jika ada beberapa pesan yang nempel
messages = buffer.split(b'\x1c\r')
for msg in messages[:-1]: # Abaikan yang terakhir (karena string kosong atau pesan belum selesai)
# Hapus pembuka MLLP (\x0b)
clean_msg_bytes = msg.replace(b'\x0b', b'')
hl7_str = clean_msg_bytes.decode('latin-1', errors='ignore')
if "MSH|" in hl7_str:
# Potong tepat dari MSH
hl7_str = hl7_str[hl7_str.find("MSH|"):]
# Ambil Control ID untuk ACK
incoming_control_id = ""
try:
msh_fields = hl7_str.split('\r')[0].split('|')
if len(msh_fields) > 9:
incoming_control_id = msh_fields[9]
except: pass
# --- PROSES HASIL (ORU) ---
if "ORU^" in hl7_str:
logging.info(f"[MYLA] Menerima hasil (ORU) ID: {incoming_control_id}")
parse_myla_result(hl7_str, device_name=f"MYLA-{addr[0]}")
# --- PROSES QUERY (QRY/QBP) - JIKA MYLA BERTANYA ORDER ---
elif "QRY^" in hl7_str or "QBP^" in hl7_str:
logging.info(f"[MYLA] Menerima Query (Belum diimplementasikan detail)")
# Anda bisa menambahkan logika lookup order disini mirip dengan GeneXpert
pass
# --- KIRIM ACK ---
if incoming_control_id:
ack_time = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
ack_msg = f"MSH|^~\\&|LIS|LAB|MYLA|bioMerieux|{ack_time}||ACK|{incoming_control_id}|P|2.5\rMSA|AA|{incoming_control_id}\r"
full_ack = f"\x0b{ack_msg}\x1c\r"
conn.sendall(full_ack.encode('utf-8'))
logging.info(f"[MYLA ACK] Terkirim untuk ID {incoming_control_id}")
# Sisakan bagian terakhir di buffer (kalau ada pesan yang terpotong)
buffer = messages[-1]
except Exception as e:
logging.error(f"[MYLA-TCP] Error koneksi {addr}: {e}")
finally:
conn.close()
logging.info(f"[MYLA-TCP] Koneksi {addr} ditutup.")
# Tambahkan fungsi starter ini di atas blok if __name__ == '__main__':
def start_myla_server(host, port):
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind((host, port))
server.listen(5)
logging.info(f"[START] MYLA TCP Server berjalan di {host}:{port}")
while True:
client_socket, addr = server.accept()
# Jalankan di thread terpisah agar bisa tangani banyak koneksi
client_thread = threading.Thread(target=handle_myla_client, args=(client_socket, addr))
client_thread.daemon = True
client_thread.start()
# ==========================================
# HL7 TCP LISTENER FOR GENEXPERT
# ==========================================
@@ -1731,6 +1907,11 @@ if __name__ == "__main__":
t_tcp.start()
all_threads.append(t_tcp)
# 3. Start Thread TCP Server (MyLA)
myla_thread = threading.Thread(target=start_myla_server, args=(MYLA_HOST, MYLA_PORT))
myla_thread.start()
all_threads.append(myla_thread)
# 4. Start Thread HTTP API (Trigger dari Laravel)
t_http = threading.Thread(target=run_http_api_server, name="Manager-HTTP-API", daemon=True)
t_http.start()