update
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user