Fix to using most recent development translation set on development demo.
[openemr.git] / interface / orders / receive_hl7_results.inc.php
blob077414ba089fba8c13d347adeb9953a616f76cde
1 <?php
2 /**
3 * Functions to support parsing and saving hl7 results.
5 * Copyright (C) 2013 Rod Roark <rod@sunsetsystems.com>
7 * LICENSE: This program is free software; you can redistribute it and/or
8 * modify it under the terms of the GNU General Public License
9 * as published by the Free Software Foundation; either version 2
10 * of the License, or (at your option) any later version.
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 * You should have received a copy of the GNU General Public License
16 * along with this program. If not, see <http://opensource.org/licenses/gpl-license.php>.
18 * @package OpenEMR
19 * @author Rod Roark <rod@sunsetsystems.com>
22 function rhl7InsertRow(&$arr, $tablename) {
23 if (empty($arr)) return;
25 // echo "<!-- "; // debugging
26 // print_r($arr);
27 // echo " -->\n";
29 $query = "INSERT INTO $tablename SET";
30 $binds = array();
31 $sep = '';
32 foreach ($arr as $key => $value) {
33 $query .= "$sep `$key` = ?";
34 $sep = ',';
35 $binds[] = $value;
37 $arr = array();
38 return sqlInsert($query, $binds);
41 function rhl7FlushResult(&$ares) {
42 return rhl7InsertRow($ares, 'procedure_result');
45 function rhl7FlushReport(&$arep) {
46 return rhl7InsertRow($arep, 'procedure_report');
49 function rhl7Text($s) {
50 $s = str_replace('\\S\\' ,'^' , $s);
51 $s = str_replace('\\F\\' ,'|' , $s);
52 $s = str_replace('\\R\\' ,'~' , $s);
53 $s = str_replace('\\T\\' ,'&' , $s);
54 $s = str_replace('\\X0d\\',"\r", $s);
55 $s = str_replace('\\E\\' ,'\\', $s);
56 return $s;
59 function rhl7DateTime($s) {
60 $s = preg_replace('/[^0-9]/', '', $s);
61 if (empty($s)) return '0000-00-00 00:00:00';
62 $ret = substr($s, 0, 4) . '-' . substr($s, 4, 2) . '-' . substr($s, 6, 2);
63 if (strlen($s) > 8) $ret .= ' ' . substr($s, 8, 2) . ':' . substr($s, 10, 2) . ':';
64 if (strlen($s) > 12) {
65 $ret .= substr($s, 12, 2);
66 } else {
67 $ret .= '00';
69 return $ret;
72 function rhl7Abnormal($s) {
73 if ($s == '' ) return 'no';
74 if ($s == 'A' ) return 'yes';
75 if ($s == 'H' ) return 'high';
76 if ($s == 'L' ) return 'low';
77 if ($s == 'HH') return 'vhigh';
78 if ($s == 'LL') return 'vlow';
79 return rhl7Text($s);
82 function rhl7ReportStatus($s) {
83 if ($s == 'F') return 'final';
84 if ($s == 'P') return 'prelim';
85 if ($s == 'C') return 'correct';
86 return rhl7Text($s);
89 /**
90 * Convert a lower case file extension to a MIME type.
91 * The extension comes from OBX[5][0] which is itself a huge assumption that
92 * the HL7 2.3 standard does not help with. Don't be surprised when we have to
93 * adapt to conventions of various other labs.
95 * @param string $fileext The lower case extension.
96 * @return string MIME type.
98 function rhl7MimeType($fileext) {
99 if ($fileext == 'pdf') return 'application/pdf';
100 if ($fileext == 'doc') return 'application/msword';
101 if ($fileext == 'rtf') return 'application/rtf';
102 if ($fileext == 'txt') return 'text/plain';
103 if ($fileext == 'zip') return 'application/zip';
104 return 'application/octet-stream';
108 * Extract encapsulated document data according to its encoding type.
110 * @param string $enctype Encoding type from OBX[5][3].
111 * @param string &$src Encoded data from OBX[5][4].
112 * @return string Decoded data, or FALSE if error.
114 function rhl7DecodeData($enctype, &$src) {
115 if ($enctype == 'Base64') return base64_decode($src);
116 if ($enctype == 'A' ) return rhl7Text($src);
117 if ($enctype == 'Hex') {
118 $data = '';
119 for ($i = 0; $i < strlen($src) - 1; $i += 2) {
120 $data .= chr(hexdec($src[$i] . $src[$i+1]));
122 return $data;
124 return FALSE;
128 * Parse and save.
130 * @param string &$pprow A row from the procedure_providers table.
131 * @param string &$hl7 The input HL7 text.
132 * @return string Error text, or empty if no errors.
134 function receive_hl7_results(&$hl7) {
135 if (substr($hl7, 0, 3) != 'MSH') {
136 return xl('Input does not begin with a MSH segment');
139 // End-of-line delimiter for text in procedure_result.comments
140 $commentdelim = "\n";
142 $today = time();
144 $in_message_id = '';
145 $in_ssn = '';
146 $in_dob = '';
147 $in_lname = '';
148 $in_fname = '';
149 $in_orderid = 0;
150 $in_procedure_code = '';
151 $in_report_status = '';
152 $in_encounter = 0;
154 $porow = false;
155 $pcrow = false;
156 $procedure_report_id = 0;
157 $arep = array(); // holding area for OBR and its NTE data
158 $ares = array(); // holding area for OBX and its NTE data
159 $code_seq_array = array(); // tracks sequence numbers of order codes
161 // This is so we know where we are if a segment like NTE that can appear in
162 // different places is encountered.
163 $context = '';
165 // Delimiters
166 $d0 = "\r";
167 $d1 = substr($hl7, 3, 1); // typically |
168 $d2 = substr($hl7, 4, 1); // typically ^
169 $d3 = substr($hl7, 5, 1); // typically ~
171 // We'll need the document category ID for any embedded documents.
172 $catrow = sqlQuery("SELECT id FROM categories WHERE name = ?",
173 array($GLOBALS['lab_results_category_name']));
174 if (empty($catrow['id'])) {
175 return xl('Document category for lab results does not exist') .
176 ': ' . $GLOBALS['lab_results_category_name'];
178 $results_category_id = $catrow['id'];
180 $segs = explode($d0, $hl7);
182 foreach ($segs as $seg) {
183 if (empty($seg)) continue;
185 $a = explode($d1, $seg);
187 if ($a[0] == 'MSH') {
188 $context = $a[0];
189 if ($a[8] != 'ORU^R01') {
190 return xl('Message type') . " '${a[8]}' " . xl('does not seem valid');
192 $in_message_id = $a[9];
195 else if ($a[0] == 'PID') {
196 $context = $a[0];
197 rhl7FlushResult($ares);
198 // Next line will do something only if there was a report with no results.
199 rhl7FlushReport($arep);
200 $in_ssn = $a[4];
201 $in_dob = $a[7]; // yyyymmdd format
202 $tmp = explode($d2, $a[5]);
203 $in_lname = $tmp[0];
204 $in_fname = $tmp[1];
207 else if ($a[0] == 'PV1') {
208 // Save placer encounter number if present.
209 if (!empty($a[19])) {
210 $tmp = explode($d2, $a[19]);
211 $in_encounter = intval($tmp[0]);
215 else if ($a[0] == 'ORC') {
216 $context = $a[0];
217 rhl7FlushResult($ares);
218 // Next line will do something only if there was a report with no results.
219 rhl7FlushReport($arep);
220 $porow = false;
221 $pcrow = false;
222 if ($a[2]) $in_orderid = intval($a[2]);
225 else if ($a[0] == 'NTE' && $context == 'ORC') {
226 // TBD? Is this ever used?
229 else if ($a[0] == 'OBR') {
230 $context = $a[0];
231 rhl7FlushResult($ares);
232 // Next line will do something only if there was a report with no results.
233 rhl7FlushReport($arep);
234 $procedure_report_id = 0;
235 if ($a[2]) $in_orderid = intval($a[2]);
236 $tmp = explode($d2, $a[4]);
237 $in_procedure_code = $tmp[0];
238 $in_procedure_name = $tmp[1];
239 $in_report_status = rhl7ReportStatus($a[25]);
240 if (empty($porow)) {
241 $porow = sqlQuery("SELECT * FROM procedure_order WHERE " .
242 "procedure_order_id = ?", array($in_orderid));
243 // The order must already exist. Currently we do not handle electronic
244 // results returned for manual orders.
245 if (empty($porow)) {
246 return xl('Procedure order') . " '$in_orderid' " . xl('was not found');
248 if ($in_encounter) {
249 if ($porow['encounter_id'] != $in_encounter) {
250 return xl('Encounter ID') .
251 " '" . $porow['encounter_id'] . "' " .
252 xl('for OBR placer order number') .
253 " '$in_orderid' " .
254 xl('does not match the PV1 encounter number') .
255 " '$in_encounter'";
258 else {
259 // They did not return an encounter number to verify, so more checking
260 // might be done here to make sure the patient seems to match.
262 $code_seq_array = array();
264 // Find the order line item (procedure code) that matches this result.
265 // If there is more than one, then we select the one whose sequence number
266 // is next after the last sequence number encountered for this procedure
267 // code; this assumes that result OBRs are returned in the same sequence
268 // as the corresponding OBRs in the order.
269 if (!isset($code_seq_array[$in_procedure_code])) {
270 $code_seq_array[$in_procedure_code] = 0;
272 $pcquery = "SELECT pc.* FROM procedure_order_code AS pc " .
273 "WHERE pc.procedure_order_id = ? AND pc.procedure_code = ? " .
274 "ORDER BY (procedure_order_seq <= ?), procedure_order_seq LIMIT 1";
275 $pcrow = sqlQuery($pcquery, array($in_orderid, $in_procedure_code,
276 $code_seq_array['$in_procedure_code']));
277 if (empty($pcrow)) {
278 // There is no matching procedure in the order, so it must have been
279 // added after the original order was sent, either as a manual request
280 // from the physician or as a "reflex" from the lab.
281 // procedure_source = '2' indicates this.
282 sqlInsert("INSERT INTO procedure_order_code SET " .
283 "procedure_order_id = ?, " .
284 "procedure_code = ?, " .
285 "procedure_name = ?, " .
286 "procedure_source = '2'",
287 array($in_orderid, $in_procedure_code, $in_procedure_name));
288 $pcrow = sqlQuery($pcquery, array($in_orderid, $in_procedure_code));
290 $code_seq_array[$in_procedure_code] = 0 + $pcrow['procedure_order_seq'];
291 $arep = array();
292 $arep['procedure_order_id'] = $in_orderid;
293 $arep['procedure_order_seq'] = $pcrow['procedure_order_seq'];
294 $arep['date_collected'] = rhl7DateTime($a[7]);
295 $arep['date_report'] = substr(rhl7DateTime($a[22]), 0, 10);
296 $arep['report_status'] = $in_report_status;
297 $arep['report_notes'] = '';
300 else if ($a[0] == 'NTE' && $context == 'OBR') {
301 $arep['report_notes'] .= rhl7Text($a[3]) . "\n";
304 else if ($a[0] == 'OBX') {
305 $context = $a[0];
306 rhl7FlushResult($ares);
307 if (!$procedure_report_id) {
308 $procedure_report_id = rhl7FlushReport($arep);
310 $ares = array();
311 $ares['procedure_report_id'] = $procedure_report_id;
312 $ares['result_data_type'] = substr($a[2], 0, 1); // N, S, F or E
313 $ares['comments'] = $commentdelim;
314 if ($a[2] == 'ED') {
315 // This is the case of results as an embedded document. We will create
316 // a normal patient document in the assigned category for lab results.
317 $tmp = explode($d2, $a[5]);
318 $fileext = strtolower($tmp[0]);
319 $filename = date("Ymd_His") . '.' . $fileext;
320 $data = rhl7DecodeData($tmp[3], $tmp[4]);
321 if ($data === FALSE) return xl('Invalid encapsulated data encoding type') . ': ' . $tmp[3];
322 $d = new Document();
323 $rc = $d->createDocument($porow['patient_id'], $results_category_id,
324 $filename, rhl7MimeType($fileext), $data);
325 if ($rc) return $rc; // This would be error message text.
326 $ares['document_id'] = $d->get_id();
328 else if (strlen($a[5]) > 200) {
329 // OBX-5 can be a very long string of text with "~" as line separators.
330 // The first line of comments is reserved for such things.
331 $ares['result_data_type'] = 'L';
332 $ares['result'] = '';
333 $ares['comments'] = rhl7Text($a[5]) . $commentdelim;
335 else {
336 $ares['result'] = rhl7Text($a[5]);
338 $tmp = explode($d2, $a[3]);
339 $ares['result_code'] = rhl7Text($tmp[0]);
340 $ares['result_text'] = rhl7Text($tmp[1]);
341 $ares['date'] = rhl7DateTime($a[14]);
342 $ares['facility'] = rhl7Text($a[15]);
343 $ares['units'] = rhl7Text($a[6]);
344 $ares['range'] = rhl7Text($a[7]);
345 $ares['abnormal'] = rhl7Abnormal($a[8]); // values are lab dependent
346 $ares['result_status'] = rhl7ReportStatus($a[11]);
349 else if ($a[0] == 'ZEF') {
350 // ZEF segment is treated like an OBX with an embedded Base64-encoded PDF.
351 $context = 'OBX';
352 rhl7FlushResult($ares);
353 if (!$procedure_report_id) {
354 $procedure_report_id = rhl7FlushReport($arep);
356 $ares = array();
357 $ares['procedure_report_id'] = $procedure_report_id;
358 $ares['result_data_type'] = 'E';
359 $ares['comments'] = $commentdelim;
361 $fileext = 'pdf';
362 $filename = date("Ymd_His") . '.' . $fileext;
363 $data = rhl7DecodeData('Base64', $a[2]);
364 if ($data === FALSE) return xl('ZEF segment internal error');
365 $d = new Document();
366 $rc = $d->createDocument($porow['patient_id'], $results_category_id,
367 $filename, rhl7MimeType($fileext), $data);
368 if ($rc) return $rc; // This would be error message text.
369 $ares['document_id'] = $d->get_id();
371 $ares['date'] = $arep['date_report'];
374 else if ($a[0] == 'NTE' && $context == 'OBX') {
375 $ares['comments'] .= rhl7Text($a[3]) . $commentdelim;
378 // Add code here for any other segment types that may be present.
380 else {
381 return xl('Segment name') . " '${a[0]}' " . xl('is misplaced or unknown');
385 rhl7FlushResult($ares);
386 // Next line will do something only if there was a report with no results.
387 rhl7FlushReport($arep);
388 return '';
392 * Poll all eligible labs for new results and store them in the database.
394 * @param array &$messages Receives messages of interest.
395 * @return string Error text, or empty if no errors.
397 function poll_hl7_results(&$messages) {
398 global $srcdir;
400 $messages = array();
401 $filecount = 0;
402 $badcount = 0;
404 $ppres = sqlStatement("SELECT * FROM procedure_providers ORDER BY name");
406 while ($pprow = sqlFetchArray($ppres)) {
407 $protocol = $pprow['protocol'];
408 $remote_host = $pprow['remote_host'];
409 $hl7 = '';
411 if ($protocol == 'SFTP') {
412 $remote_port = 22;
413 // Hostname may have ":port" appended to specify a nonstandard port number.
414 if ($i = strrpos($remote_host, ':')) {
415 $remote_port = 0 + substr($remote_host, $i + 1);
416 $remote_host = substr($remote_host, 0, $i);
418 ini_set('include_path', ini_get('include_path') . PATH_SEPARATOR . "$srcdir/phpseclib");
419 require_once("$srcdir/phpseclib/Net/SFTP.php");
420 // Compute the target path name.
421 $pathname = '.';
422 if ($pprow['results_path']) $pathname = $pprow['results_path'] . '/' . $pathname;
423 // Connect to the server and enumerate files to process.
424 $sftp = new Net_SFTP($remote_host, $remote_port);
425 if (!$sftp->login($pprow['login'], $pprow['password'])) {
426 return xl('Login to remote host') . " '$remote_host' " . xl('failed');
428 $files = $sftp->nlist($pathname);
429 foreach ($files as $file) {
430 if (substr($file, 0, 1) == '.') continue;
431 ++$filecount;
432 $hl7 = $sftp->get("$pathname/$file");
433 // Archive the results file.
434 $prpath = $GLOBALS['OE_SITE_DIR'] . "/procedure_results";
435 if (!file_exists($prpath)) mkdir($prpath);
436 $prpath .= '/' . $pprow['ppid'];
437 if (!file_exists($prpath)) mkdir($prpath);
438 $fh = fopen("$prpath/$file", 'w');
439 if ($fh) {
440 fwrite($fh, $hl7);
441 fclose($fh);
443 else {
444 $messages[] = xl('File') . " '$file' " . xl('cannot be archived, ignored');
445 ++$badcount;
446 continue;
448 // Now delete it from its ftp directory.
449 if (!$sftp->delete("$pathname/$file")) {
450 $messages[] = xl('File') . " '$file' " . xl('cannot be deleted, ignored');
451 ++$badcount;
452 continue;
454 // Parse and process its contents.
455 $msg = receive_hl7_results($hl7);
456 if ($msg) {
457 $tmp = xl('Error processing file') . " '$file': " . $msg;
458 $messages[] = $tmp;
459 newEvent("lab-results-error", $_SESSION['authUser'], $_SESSION['authProvider'], 0, $tmp);
460 ++$badcount;
461 continue;
463 $messages[] = xl('New file') . " '$file' " . xl('processed successfully');
467 // TBD: Insert "else if ($protocol == '???') {...}" to support other protocols.
471 if ($badcount) return "$badcount " . xl('error(s) encountered from new results');
473 return '';