added support for mariadb in the ubuntu-debian packages
[openemr.git] / interface / orders / receive_hl7_results.inc.php
blob2cc8cc4f1d5c4f2ce78a29281e5fe51a33719e15
1 <?php
2 /**
3 * Functions to support parsing and saving hl7 results.
5 * Copyright (C) 2013-2016 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>
21 * 07-2015: Ensoftek: Edited for MU2 170.314(b)(5)(A)
24 require_once("$srcdir/forms.inc");
25 require_once("$srcdir/classes/Document.class.php");
27 $rhl7_return = array();
29 function rhl7LogMsg($msg, $fatal=true) {
30 global $rhl7_return;
31 if ($fatal) {
32 $rhl7_return['mssgs'][] = '*' . $msg;
33 $rhl7_return['fatal'] = true;
34 newEvent("lab-results-error", $_SESSION['authUser'], $_SESSION['authProvider'], 0, $msg);
36 else {
37 $rhl7_return['mssgs'][] = '>' . $msg;
39 return $rhl7_return;
42 function rhl7InsertRow(&$arr, $tablename) {
43 if (empty($arr)) return;
45 // echo "<!-- "; // debugging
46 // print_r($arr);
47 // echo " -->\n";
49 $query = "INSERT INTO $tablename SET";
50 $binds = array();
51 $sep = '';
52 foreach ($arr as $key => $value) {
53 $query .= "$sep `$key` = ?";
54 $sep = ',';
55 $binds[] = $value;
57 $arr = array();
58 return sqlInsert($query, $binds);
61 // Write all of the accumulated reports and their results.
62 function rhl7FlushMain(&$amain, $commentdelim="\n") {
63 foreach ($amain as $arr) {
64 $procedure_report_id = rhl7InsertRow($arr['rep'], 'procedure_report');
65 foreach ($arr['res'] as $ares) {
66 $ares['procedure_report_id'] = $procedure_report_id;
67 // obxkey was used to identify parent results but is not stored.
68 unset($ares['obxkey']);
69 // If TX result is not over 10 characters, move it from comments to result field.
70 if ($ares['result'] === '' && $ares['result_data_type'] == 'L') {
71 $i = strpos($ares['comments'], $commentdelim);
72 if ($i && $i <= 10) {
73 $ares['result' ] = substr($ares['comments'], 0, $i);
74 $ares['comments'] = substr($ares['comments'], $i);
77 rhl7InsertRow($ares, 'procedure_result');
82 // Write the MDM document if appropriate.
84 function rhl7FlushMDM($patient_id, $mdm_docname, $mdm_datetime, $mdm_text, $mdm_category_id, $provider) {
85 if ($patient_id) {
86 if (!empty($mdm_docname)) $mdm_docname .= '_';
87 $mdm_docname .= preg_replace('/[^0-9]/', '', $mdm_datetime);
88 $filename = $mdm_docname . '.txt';
89 $d = new Document();
90 $rc = $d->createDocument($patient_id, $mdm_category_id, $filename, 'text/plain', $mdm_text);
91 if (!$rc) {
92 rhl7LogMsg(xl('Document created') . ": $filename", false);
93 if ($provider) {
94 $d->postPatientNote($provider, $mdm_category_id, xl('Electronic document received'));
95 rhl7LogMsg(xl('Notification sent to') . ": $provider", false);
97 else {
98 rhl7LogMsg(xl('No provider was matched'), false);
101 return $rc;
103 return '';
106 function rhl7Text($s, $allow_newlines=false) {
107 $s = str_replace('\\S\\' ,'^' , $s);
108 $s = str_replace('\\F\\' ,'|' , $s);
109 $s = str_replace('\\R\\' ,'~' , $s);
110 $s = str_replace('\\T\\' ,'&' , $s);
111 $s = str_replace('\\X0d\\',"\r", $s);
112 $s = str_replace('\\E\\' ,'\\', $s);
113 if ($allow_newlines) {
114 $s = str_replace('\\.br\\',"\n", $s);
116 else {
117 $s = str_replace('\\.br\\','~' , $s);
119 return $s;
122 function rhl7DateTime($s) {
123 // Remove UTC offset if present.
124 if (preg_match('/^([0-9.]+)[+-]/', $s, $tmp)) {
125 $s = $tmp[1];
127 $s = preg_replace('/[^0-9]/', '', $s);
128 if (empty($s)) return '0000-00-00 00:00:00';
129 $ret = substr($s, 0, 4) . '-' . substr($s, 4, 2) . '-' . substr($s, 6, 2);
130 if (strlen($s) > 8) {
131 $ret .= ' ' . substr($s, 8, 2) . ':' . substr($s, 10, 2) . ':';
132 if (strlen($s) > 12) {
133 $ret .= substr($s, 12, 2);
134 } else {
135 $ret .= '00';
138 return $ret;
141 function rhl7DateTimeZone($s) {
142 // UTC offset if present always begins with "+" or "-".
143 if (preg_match('/^[0-9.]+([+-].*)$/', $s, $tmp)) {
144 return trim($tmp[1]);
146 return '';
149 function rhl7Date($s) {
150 return substr(rhl7DateTime($s), 0, 10);
153 function rhl7Abnormal($s) {
154 if ($s == '' ) return 'no';
155 if ($s == 'N' ) return 'no';
156 if ($s == 'A' ) return 'yes';
157 if ($s == 'H' ) return 'high';
158 if ($s == 'L' ) return 'low';
159 if ($s == 'HH') return 'vhigh';
160 if ($s == 'LL') return 'vlow';
161 return rhl7Text($s);
164 function rhl7ReportStatus($s) {
165 if ($s == 'F') return 'final';
166 if ($s == 'P') return 'prelim';
167 if ($s == 'C') return 'correct';
168 if ($s == 'X') return 'error';
169 return rhl7Text($s);
173 * Convert a lower case file extension to a MIME type.
174 * The extension comes from OBX[5][0] which is itself a huge assumption that
175 * the HL7 2.3 standard does not help with. Don't be surprised when we have to
176 * adapt to conventions of various other labs.
178 * @param string $fileext The lower case extension.
179 * @return string MIME type.
181 function rhl7MimeType($fileext) {
182 if ($fileext == 'pdf') return 'application/pdf';
183 if ($fileext == 'doc') return 'application/msword';
184 if ($fileext == 'rtf') return 'application/rtf';
185 if ($fileext == 'txt') return 'text/plain';
186 if ($fileext == 'zip') return 'application/zip';
187 return 'application/octet-stream';
191 * Extract encapsulated document data according to its encoding type.
193 * @param string $enctype Encoding type from OBX[5][3].
194 * @param string &$src Encoded data from OBX[5][4].
195 * @return string Decoded data, or FALSE if error.
197 function rhl7DecodeData($enctype, &$src) {
198 if ($enctype == 'Base64') return base64_decode($src);
199 if ($enctype == 'A' ) return rhl7Text($src);
200 if ($enctype == 'Hex') {
201 $data = '';
202 for ($i = 0; $i < strlen($src) - 1; $i += 2) {
203 $data .= chr(hexdec($src[$i] . $src[$i+1]));
205 return $data;
207 return FALSE;
210 function rhl7CWE($s, $componentdelimiter) {
211 $out = '';
212 if ($s === '') return $out;
213 $arr = explode($componentdelimiter, $s);
214 if (!empty($arr[8])) {
215 $out = $arr[8];
217 else {
218 $out = $arr[0];
219 if (isset($arr[1])) {
220 $out .= " (" . $arr[1] . ")";
223 return $out;
227 * Parse the SPM segment and get the specimen display name and update the table.
229 * @param string $specimen Encoding type from SPM.
231 function rhl7UpdateReportWithSpecimen(&$amain, $specimen, $d2) {
232 $specimen_display = '';
234 // SPM4: Specimen Type: Example: 119297000^BLD^SCT^BldSpc^Blood^99USA^^^Blood Specimen
235 $specimen_display = rhl7CWE($specimen[4], $d2);
237 $tmpnotes = xl('Specimen type') . ': ' . $specimen_display;
238 $tmp = rhl7CWE($specimen[21], $d2);
239 if ($tmp) {
240 $tmpnotes .= '; ' . xl('Rejected') . ': ' . $tmp;
242 $tmp = rhl7CWE($specimen[24], $d2);
243 if ($tmp) {
244 $tmpnotes .= '; ' . xl('Condition') . ': ' . $tmp;
247 $alast = count($amain) - 1;
248 $amain[$alast]['rep']['specimen_num'] = $specimen_display;
249 $amain[$alast]['rep']['report_notes'] .= rhl7Text($tmpnotes) . "\n";
253 * Get the Performing Lab Details from the OBX segment. Mandatory for MU2.
255 * @param string $obx23 Encoding type from OBX23.
256 * @param string $obx23 Encoding type from OBX24.
257 * @param string $obx23 Encoding type from OBX25.
258 * @param string $obx23 New line character.
260 function getPerformingOrganizationDetails($obx23, $obx24, $obx25, $componentdelimiter, $commentdelim) {
261 $s = null;
263 if ( !empty($obx24) || !empty($obx24) || !empty($obx25) )
265 // Organization Name
266 // OBX23 Example: "Century Hospital^^^^^NIST-AA-1&2.16.840.1.113883.3.72.5.30.1&ISO^XX^^^987"
267 $obx23_segs = explode($componentdelimiter, $obx23);
268 if ( !empty($obx23_segs[0]) )
270 $s .= $obx23_segs[0] . $commentdelim;
273 // Medical Director
274 // OBX25 Example: "2343242^Knowsalot^Phil^J.^III^Dr.^^^NIST-AA-1&2.16.840.1.113883.3.72.5.30.1&ISO^L^^^DNSPM"
275 // Dr. Phil Knowsalot J. III
276 if ( !empty($obx25) )
278 $obx25_segs = explode($componentdelimiter, $obx25);
279 $s .= "$obx25_segs[5] $obx25_segs[2] $obx25_segs[1] $obx25_segs[3] $obx25_segs[4]" . $commentdelim;
282 // Organization Address
283 // OBX24 Example: "2070 Test Park^^Los Angeles^CA^90067^USA^B^^06037"
284 if ( !empty($obx24) )
286 $obx24_segs = explode($componentdelimiter, $obx24);
287 //$s .= "$obx24_segs[0] $obx24_segs[1], $obx24_segs[2], $obx24_segs[3], $obx24_segs[4], $obx24_segs[5]" . $commentdelim;
288 $s .= "$obx24_segs[0]$commentdelim$obx24_segs[2], $obx24_segs[3] $obx24_segs[4]$commentdelim$obx24_segs[5]$commentdelim";
289 if ( !empty($obx24_segs[8]) )
291 $s .= "County/Parish Code: $obx24_segs[8]$commentdelim";
295 return $s;
299 * Look for a patient matching the given data.
300 * Return values are:
301 * >0 Definite match, this is the pid.
302 * 0 No patient is close to a match.
303 * -1 It's not clear if there is a match.
305 function match_patient($ptarr) {
306 $in_ss = str_replace('-', '', $ptarr['ss']);
307 $in_fname = $ptarr['fname'];
308 $in_lname = $ptarr['lname'];
309 $in_dob = $ptarr['DOB'];
310 $patient_id = 0;
311 $res = sqlStatement("SELECT pid FROM patient_data WHERE " .
312 "((ss IS NULL OR ss = '' OR '' = ?) AND " .
313 "fname IS NOT NULL AND fname != '' AND fname = ? AND " .
314 "lname IS NOT NULL AND lname != '' AND lname = ? AND " .
315 "DOB IS NOT NULL AND DOB = ?) OR " .
316 "(ss IS NOT NULL AND ss != '' AND REPLACE(ss, '-', '') = ? AND (" .
317 "fname IS NOT NULL AND fname != '' AND fname = ? OR " .
318 "lname IS NOT NULL AND lname != '' AND lname = ? OR " .
319 "DOB IS NOT NULL AND DOB = ?)) " .
320 "ORDER BY ss DESC, pid DESC LIMIT 2",
321 array($in_ss, $in_fname, $in_lname, $in_dob, $in_ss, $in_fname, $in_lname, $in_dob));
322 if (sqlNumRows($res) > 1) {
323 // Multiple matches, so ambiguous.
324 $patient_id = -1;
326 else if (sqlNumRows($res) == 1) {
327 // Got exactly one match, so use it.
328 $tmp = sqlFetchArray($res);
329 $patient_id = intval($tmp['pid']);
331 else {
332 // No match good enough, figure out if there's enough ambiguity to ask the user.
333 $tmp = sqlQuery("SELECT pid FROM patient_data WHERE " .
334 "(ss IS NOT NULL AND ss != '' AND REPLACE(ss, '-', '') = ?) OR " .
335 "(fname IS NOT NULL AND fname != '' AND fname = ? AND " .
336 "lname IS NOT NULL AND lname != '' AND lname = ?) OR " .
337 "(DOB IS NOT NULL AND DOB = ?) " .
338 "LIMIT 1",
339 array($in_ss, $in_fname, $in_lname, $in_dob));
340 if (!empty($tmp['pid'])) {
341 $patient_id = -1;
344 return $patient_id;
348 * Look for a local provider matching the given XCN field from some segment.
350 * @param array $arr array(NPI, lastname, firstname) identifying a provider.
351 * @return mixed Array(id, username), or FALSE if no match.
353 function match_provider($arr) {
354 if (empty($arr)) return false;
355 $op_lname = $op_fname = '';
356 $op_npi = preg_replace('/[^0-9]/', '', $arr[0]);
357 if (!empty($arr[1])) $op_lname = $arr[1];
358 if (!empty($arr[2])) $op_fname = $arr[2];
359 if ($op_npi || ($op_fname && $op_lname)) {
360 if ($op_npi) {
361 if ($op_fname && $op_lname) {
362 $where = "((npi IS NOT NULL AND npi = ?) OR ((npi IS NULL OR npi = ?) AND lname = ? AND fname = ?))";
363 $qarr = array($op_npi, '', $op_lname, $op_fname);
365 else {
366 $where = "npi IS NOT NULL AND npi = ?";
367 $qarr = array($op_npi);
370 else {
371 $where = "lname = ? AND fname = ?";
372 $qarr = array($op_lname, $op_fname);
374 $oprow = sqlQuery("SELECT id, username FROM users WHERE " .
375 "username IS NOT NULL AND username != '' AND $where " .
376 "ORDER BY active DESC, authorized DESC, username, id LIMIT 1",
377 $qarr);
378 if (!empty($oprow)) return $oprow;
380 return false;
384 * Create a patient using whatever patient_data attributes are provided.
386 function create_skeleton_patient($patient_data) {
387 $employer_data = array();
388 $tmp = sqlQuery("SELECT MAX(pid)+1 AS pid FROM patient_data");
389 $ptid = empty($tmp['pid']) ? 1 : intval($tmp['pid']);
390 if (!isset($patient_data['pubpid'])) $patient_data['pubpid'] = $ptid;
391 updatePatientData($ptid, $patient_data, true);
392 updateEmployerData($ptid, $employer_data, true);
393 newHistoryData($ptid);
394 return $ptid;
398 * Parse and save.
400 * @param string &$hl7 The input HL7 text
401 * @param string &$matchreq Array of shared patient matching requests
402 * @param int $lab_id Lab ID
403 * @param char $direction B=Bidirectional, R=Results-only
404 * @param bool $dryrun True = do not update anything, just report errors
405 * @param array $matchresp Array of responses to match requests; key is relative segment number,
406 * value is an existing pid or 0 to specify creating a patient
407 * @return array Array of errors and match requests, if any
409 function receive_hl7_results(&$hl7, &$matchreq, $lab_id=0, $direction='B', $dryrun=false, $matchresp=NULL) {
410 global $rhl7_return;
412 // This will hold returned error messages and related variables.
413 $rhl7_return = array();
414 $rhl7_return['mssgs'] = array();
415 $rhl7_return['needmatch'] = false; // indicates if this file is pending a match request
417 $rhl7_segnum = 0;
419 if (substr($hl7, 0, 3) != 'MSH') {
420 return rhl7LogMsg(xl('Input does not begin with a MSH segment'), true);
423 // This array holds everything to be written to the database.
424 // We save and postpone these writes in case of errors while processing the message,
425 // so we can look up data from parent results when child results are encountered,
426 // and for other logic simplification.
427 // Each element of this array is another array containing the following possible keys:
428 // 'rep' - row of data to write to procedure_report
429 // 'res' - array of rows to write to procedure_result for this procedure_report
430 // 'fid' - unique lab-provided identifier for this report
432 $amain = array();
434 // End-of-line delimiter for text in procedure_result.comments and other multi-line notes.
435 $commentdelim = "\n";
437 // Ensoftek: Different labs seem to send different EOLs. Edit HL7 input to a character we know.
438 $hl7 = (string)str_replace(array("\r\n", "\r", "\n"), "\r", $hl7);
440 $today = time();
442 $in_message_id = '';
443 $in_ssn = '';
444 $in_dob = '';
445 $in_lname = '';
446 $in_fname = '';
447 $in_orderid = 0;
448 $in_procedure_code = '';
449 $in_report_status = '';
450 $in_encounter = 0;
451 $patient_id = 0; // for results-only patient matching logic
452 $porow = false;
453 $pcrow = false;
454 $oprow = false;
456 $code_seq_array = array(); // tracks sequence numbers of order codes
457 $results_category_id = 0; // document category ID for lab results
459 // This is so we know where we are if a segment like NTE that can appear in
460 // different places is encountered.
461 $context = '';
463 // This will be "ORU" or "MDM".
464 $msgtype = '';
466 // Stuff collected for MDM documents.
467 $mdm_datetime = '';
468 $mdm_docname = '';
469 $mdm_text = '';
471 // Delimiters
472 $d0 = "\r";
473 $d1 = substr($hl7, 3, 1); // typically |
474 $d2 = substr($hl7, 4, 1); // typically ^
475 $d3 = substr($hl7, 5, 1); // typically ~
476 $d4 = substr($hl7, 6, 1); // typically \
477 $d5 = substr($hl7, 7, 1); // typically &
479 // We'll need the document category IDs for any embedded documents.
480 $catrow = sqlQuery("SELECT id FROM categories WHERE name = ?",
481 array($GLOBALS['lab_results_category_name']));
482 if (empty($catrow['id'])) {
483 return rhl7LogMsg(xl('Document category for lab results does not exist') .
484 ': ' . $GLOBALS['lab_results_category_name'], true);
486 else {
487 $results_category_id = $catrow['id'];
488 $mdm_category_id = $results_category_id;
489 $catrow = sqlQuery("SELECT id FROM categories WHERE name = ?",
490 array($GLOBALS['gbl_mdm_category_name']));
491 if (!empty($catrow['id'])) $mdm_category_id = $catrow['id'];
494 $segs = explode($d0, $hl7);
496 foreach ($segs as $seg) {
497 if (empty($seg)) continue;
499 // echo "<!-- $dryrun $seg -->\n"; // debugging
501 ++$rhl7_segnum;
502 $a = explode($d1, $seg);
504 if ($a[0] == 'MSH') {
505 if (!$dryrun) {
506 rhl7FlushMain($amain, $commentdelim);
508 $amain = array();
510 if ('MDM' == $msgtype && !$dryrun) {
511 $rc = rhl7FlushMDM($patient_id, $mdm_docname, $mdm_datetime, $mdm_text, $mdm_category_id,
512 $oprow ? $oprow['username'] : 0);
513 if ($rc) return rhl7LogMsg($rc);
514 $patient_id = 0;
517 $context = $a[0];
518 // Ensoftek: Could come is as 'ORU^R01^ORU_R01'. Handle all cases when 'ORU^R01' is seen.
519 if (strstr($a[8], "ORU^R01")) {
520 $msgtype = 'ORU';
522 else if ($a[8] == 'MDM^T02' || $a[8] == 'MDM^T04' || $a[8] == 'MDM^T08') {
523 $msgtype = 'MDM';
524 $mdm_datetime = '';
525 $mdm_docname = '';
526 $mdm_text = '';
528 else {
529 return rhl7LogMsg(xl('MSH.8 message type is not supported') . ": '" . $a[8] . "'", true);
531 $in_message_id = $a[9];
534 else if ($a[0] == 'PID') {
535 $context = $a[0];
537 if ('MDM' == $msgtype && !$dryrun) {
538 $rc = rhl7FlushMDM($patient_id, $mdm_docname, $mdm_datetime, $mdm_text, $mdm_category_id,
539 $oprow ? $oprow['username'] : 0);
540 if ($rc) return rhl7LogMsg($rc);
543 $porow = false;
544 $pcrow = false;
545 $oprow = false;
546 $in_orderid = 0;
547 $in_ssn = preg_replace('/[^0-9]/', '', $a[4]);
548 $in_dob = rhl7Date($a[7]);
549 $tmp = explode($d2, $a[5]);
550 $in_lname = rhl7Text($tmp[0]);
551 $in_fname = rhl7Text($tmp[1]);
552 $in_mname = rhl7Text($tmp[2]);
553 $patient_id = 0;
554 // Patient matching is needed for a results-only interface or MDM message type.
555 if ('R' == $direction || 'MDM' == $msgtype) {
556 $ptarr = array('ss' => strtoupper($in_ss), 'fname' => strtoupper($in_fname),
557 'lname' => strtoupper($in_lname), 'mname' => strtoupper($in_mname),
558 'DOB' => strtoupper($in_dob));
559 $patient_id = match_patient($ptarr);
560 if ($patient_id == -1) {
561 // Result is indeterminate.
562 // Make a stringified form of $ptarr to use as a key.
563 $ptstring = serialize($ptarr);
564 // Check if the user has specified the patient.
565 if (isset($matchresp[$ptstring])) {
566 // This will be an existing pid, or 0 to specify creating a patient.
567 $patient_id = intval($matchresp[$ptstring]);
569 else {
570 if ($dryrun) {
571 // Nope, ask the user to match.
572 $matchreq[$ptstring] = true;
573 $rhl7_return['needmatch'] = true;
575 else {
576 // Should not happen, but it would be bad to abort now. Create the patient.
577 $patient_id = 0;
578 rhl7LogMsg(xl('Unexpected non-match, creating new patient for segment') .
579 ' ' . $rhl7_segnum, false);
583 if ($patient_id == 0 && !$dryrun) {
584 // We must create the patient.
585 $patient_id = create_skeleton_patient($ptarr);
587 if ($patient_id == -1) $patient_id = 0;
588 } // end results-only/MDM logic
591 else if ('PD1' == $a[0]) {
592 // TBD: Save primary care provider name ($a[4]) somewhere?
595 else if ('PV1' == $a[0]) {
596 if ('ORU' == $msgtype) {
597 // Save placer encounter number if present.
598 if ($direction != 'R' && !empty($a[19])) {
599 $tmp = explode($d2, $a[19]);
600 $in_encounter = intval($tmp[0]);
603 else if ('MDM' == $msgtype) {
604 // For documents we want the ordering provider.
605 // Try Referring Provider first.
606 $oprow = match_provider(explode($d2, $a[8]));
607 // If no match, try Other Provider.
608 if (empty($oprow)) $oprow = match_provider(explode($d2, $a[52]));
612 else if ('ORC' == $a[0] && 'ORU' == $msgtype) {
613 $context = $a[0];
614 $arep = array();
615 $porow = false;
616 $pcrow = false;
617 if ($direction != 'R' && $a[2]) $in_orderid = intval($a[2]);
620 else if ('TXA' == $a[0] && 'MDM' == $msgtype) {
621 $context = $a[0];
622 $mdm_datetime = rhl7DateTime($a[4]);
623 $mdm_docname = rhl7Text($a[12]);
626 else if ($a[0] == 'NTE' && ($context == 'ORC' || $context == 'TXA')) {
627 // Is this ever used?
630 else if ('OBR' == $a[0] && 'ORU' == $msgtype) {
631 $context = $a[0];
632 $arep = array();
633 if ($direction != 'R' && $a[2]) {
634 $in_orderid = intval($a[2]);
635 $porow = false;
636 $pcrow = false;
638 $tmp = explode($d2, $a[4]);
639 $in_procedure_code = $tmp[0];
640 $in_procedure_name = $tmp[1];
641 $in_report_status = rhl7ReportStatus($a[25]);
643 // Filler identifier is supposed to be unique for each incoming report.
644 $in_filler_id = $a[3];
645 // Child results will have these pointers to their parent.
646 $in_parent_obrkey = '';
647 $in_parent_obxkey = '';
648 $parent_arep = false; // parent report, if any
649 $parent_ares = false; // parent result, if any
650 if (!empty($a[29])) {
651 // This is a child so there should be a parent.
652 $tmp = explode($d2, $a[29]);
653 $in_parent_obrkey = str_replace($d5, $d2, $tmp[1]);
654 $tmp = explode($d2, $a[26]);
655 $in_parent_obxkey = str_replace($d5, $d2, $tmp[0]) . $d1 . $tmp[1];
656 // Look for the parent report.
657 foreach ($amain as $arr) {
658 if (isset($arr['fid']) && $arr['fid'] == $in_parent_obrkey) {
659 $parent_arep = $arr['rep'];
660 // Now look for the parent result within that report.
661 foreach ($arr['res'] as $tmpres) {
662 if (isset($tmpres['obxkey']) && $tmpres['obxkey'] == $in_parent_obxkey) {
663 $parent_ares = $tmpres;
664 break;
667 break;
672 if ($parent_arep) {
673 $in_orderid = $parent_arep['procedure_order_id'];
676 if ($direction == 'R') {
677 // Save their order ID to procedure_order.control_id.
678 // Look for an existing order using that plus lab_id.
679 // Ordering provider is OBR.16 (NPI^Last^First).
680 // Might not need to create a dummy encounter.
681 // Need also provider_id (probably), patient_id, date_ordered, lab_id.
682 // We have observation date/time in OBR.7.
683 // We have report date/time in OBR.22.
684 // We do not have an order date.
686 $external_order_id = empty($a[2]) ? $a[3] : $a[2];
687 $porow = false;
689 if (!$in_orderid && $external_order_id) {
690 $porow = sqlQuery("SELECT * FROM procedure_order " .
691 "WHERE lab_id = ? AND control_id = ? " .
692 "ORDER BY procedure_order_id DESC LIMIT 1",
693 array($lab_id, $external_order_id));
695 if (!empty($porow)) {
696 $in_orderid = intval($porow['procedure_order_id']);
699 if (!$in_orderid) {
700 // Create order.
701 // Need to identify the ordering provider and, if possible, a recent encounter.
702 $datetime_report = rhl7DateTime($a[22]);
703 $date_report = substr($datetime_report, 0, 10) . ' 00:00:00';
704 $encounter_id = 0;
705 $provider_id = 0;
706 // Look for the most recent encounter within 30 days of the report date.
707 $encrow = sqlQuery("SELECT encounter FROM form_encounter WHERE " .
708 "pid = ? AND date <= ? AND DATE_ADD(date, INTERVAL 30 DAY) > ? " .
709 "ORDER BY date DESC, encounter DESC LIMIT 1",
710 array($patient_id, $date_report, $date_report));
711 if (!empty($encrow)) {
712 $encounter_id = intval($encrow['encounter']);
713 $provider_id = intval($encrow['provider_id']);
715 if (!$provider_id) {
716 // Attempt ordering provider matching by name or NPI.
717 $oprow = match_provider(explode($d2, $a[16]));
718 if (!empty($oprow)) $provider_id = intval($oprow['id']);
720 if (!$dryrun) {
721 // Now create the procedure order.
722 $in_orderid = sqlInsert("INSERT INTO procedure_order SET " .
723 "date_ordered = ?, " .
724 "provider_id = ?, " .
725 "lab_id = ?, " .
726 "date_collected = ?, " .
727 "date_transmitted = ?, " .
728 "patient_id = ?, " .
729 "encounter_id = ?, " .
730 "control_id = ?",
731 array($datetime_report, $provider_id, $lab_id, rhl7DateTime($a[22]),
732 rhl7DateTime($a[7]), $patient_id, $encounter_id, $external_order_id));
733 // If an encounter was identified then link the order to it.
734 if ($encounter_id && $in_orderid) {
735 addForm($encounter_id, "Procedure Order", $in_orderid, "procedure_order", $patient_id);
738 } // end no $porow
739 } // end results-only
740 if (empty($porow)) {
741 $porow = sqlQuery("SELECT * FROM procedure_order WHERE " .
742 "procedure_order_id = ?", array($in_orderid));
743 // The order must already exist. Currently we do not handle electronic
744 // results returned for manual orders.
745 if (empty($porow) && !($dryrun && $direction == 'R')) {
746 return rhl7LogMsg(xl('Procedure order not found') . ": $in_orderid", true);
748 if ($in_encounter) {
749 if ($direction != 'R' && $porow['encounter_id'] != $in_encounter) {
750 return rhl7LogMsg(xl('Encounter ID') .
751 " '" . $porow['encounter_id'] . "' " .
752 xl('for OBR placer order number') .
753 " '$in_orderid' " .
754 xl('does not match the PV1 encounter number') .
755 " '$in_encounter'");
758 else {
759 // They did not return an encounter number to verify, so more checking
760 // might be done here to make sure the patient seems to match.
762 // Save the lab's control ID if there is one.
763 $tmp = explode($d2, $a[3]);
764 $control_id = $tmp[0];
765 if ($control_id && empty($porow['control_id'])) {
766 sqlStatement("UPDATE procedure_order SET control_id = ? WHERE " .
767 "procedure_order_id = ?", array($control_id, $in_orderid));
769 $code_seq_array = array();
771 // Find the order line item (procedure code) that matches this result.
772 // If there is more than one, then we select the one whose sequence number
773 // is next after the last sequence number encountered for this procedure
774 // code; this assumes that result OBRs are returned in the same sequence
775 // as the corresponding OBRs in the order.
776 if (!isset($code_seq_array[$in_procedure_code])) {
777 $code_seq_array[$in_procedure_code] = 0;
779 $pcquery = "SELECT pc.* FROM procedure_order_code AS pc " .
780 "WHERE pc.procedure_order_id = ? AND pc.procedure_code = ? " .
781 "ORDER BY (procedure_order_seq <= ?), procedure_order_seq LIMIT 1";
782 $pcqueryargs = array($in_orderid, $in_procedure_code, $code_seq_array[$in_procedure_code]);
783 $pcrow = sqlQuery($pcquery, $pcqueryargs);
784 if (empty($pcrow)) {
785 // There is no matching procedure in the order, so it must have been
786 // added after the original order was sent, either as a manual request
787 // from the physician or as a "reflex" from the lab.
788 // procedure_source = '2' indicates this.
789 if (!$dryrun) {
790 sqlInsert("INSERT INTO procedure_order_code SET " .
791 "procedure_order_id = ?, " .
792 "procedure_code = ?, " .
793 "procedure_name = ?, " .
794 "procedure_source = '2'",
795 array($in_orderid, $in_procedure_code, $in_procedure_name));
796 $pcrow = sqlQuery($pcquery, $pcqueryargs);
798 else {
799 // Dry run, make a dummy procedure_order_code row.
800 $pcrow = array(
801 'procedure_order_id' => $in_orderid,
802 'procedure_order_seq' => 0, // TBD?
806 $code_seq_array[$in_procedure_code] = 0 + $pcrow['procedure_order_seq'];
807 $arep = array();
808 $arep['procedure_order_id'] = $in_orderid;
809 $arep['procedure_order_seq'] = $pcrow['procedure_order_seq'];
810 $arep['date_collected'] = rhl7DateTime($a[7]);
811 $arep['date_collected_tz'] = rhl7DateTimeZone($a[7]);
812 $arep['date_report'] = rhl7DateTime($a[22]);
813 $arep['date_report_tz'] = rhl7DateTimeZone($a[22]);
814 $arep['report_status'] = $in_report_status;
815 $arep['report_notes'] = '';
816 $arep['specimen_num'] = '';
818 // If this is a child report, add some info from the parent.
819 if (!empty($parent_ares)) {
820 $arep['report_notes'] .= xl('This is a child of result') . ' ' .
821 $parent_ares['result_code'] . ' ' . xl('with value') . ' "' .
822 $parent_ares['result'] . '".' . "\n";
824 if (!empty($parent_arep)) {
825 $arep['report_notes'] .= $parent_arep['report_notes'];
826 $arep['specimen_num'] = $parent_arep['specimen_num'];
829 // Create the main array entry for this report and its results.
830 $i = count($amain);
831 $amain[$i] = array();
832 $amain[$i]['rep'] = $arep;
833 $amain[$i]['fid'] = $in_filler_id;
834 $amain[$i]['res'] = array();
837 else if ($a[0] == 'NTE' && $context == 'OBR') {
838 // Append this note to those for the most recent report.
839 $amain[count($amain)-1]['rep']['report_notes'] .= rhl7Text($a[3], true) . "\n";
842 else if ('OBX' == $a[0] && 'ORU' == $msgtype) {
843 $tmp = explode($d2, $a[3]);
844 $result_code = rhl7Text($tmp[0]);
845 $result_text = rhl7Text($tmp[1]);
846 // If this is a text result that duplicates the previous result except
847 // for its value, then treat it as an extension of that result's value.
848 $i = count($amain) - 1;
849 $j = count($amain[$i]['res']) - 1;
850 if ($j >= 0 && $context == 'OBX' && $a[2] == 'TX'
851 && $amain[$i]['res'][$j]['result_data_type'] == 'L'
852 && $amain[$i]['res'][$j]['result_code' ] == $result_code
853 && $amain[$i]['res'][$j]['date' ] == rhl7DateTime($a[14])
854 && $amain[$i]['res'][$j]['facility' ] == rhl7Text($a[15])
855 && $amain[$i]['res'][$j]['abnormal' ] == rhl7Abnormal($a[8])
856 && $amain[$i]['res'][$j]['result_status' ] == rhl7ReportStatus($a[11])
858 $amain[$i]['res'][$j]['comments'] =
859 substr($amain[$i]['res'][$j]['comments'], 0, strlen($amain[$i]['res'][$j]['comments']) - 1) .
860 '~' . rhl7Text($a[5]) . $commentdelim;
861 continue;
863 $context = $a[0];
864 $ares = array();
865 $ares['result_data_type'] = substr($a[2], 0, 1); // N, S, F or E
866 $ares['comments'] = $commentdelim;
867 if ($a[2] == 'ED') {
868 // This is the case of results as an embedded document. We will create
869 // a normal patient document in the assigned category for lab results.
870 $tmp = explode($d2, $a[5]);
871 $fileext = strtolower($tmp[0]);
872 $filename = date("Ymd_His") . '.' . $fileext;
873 $data = rhl7DecodeData($tmp[3], $tmp[4]);
874 if ($data === FALSE) {
875 return rhl7LogMsg(xl('Invalid encapsulated data encoding type') . ': ' . $tmp[3]);
877 if (!$dryrun) {
878 $d = new Document();
879 $rc = $d->createDocument($porow['patient_id'], $results_category_id, // TBD: Make sure not 0
880 $filename, rhl7MimeType($fileext), $data);
881 if ($rc) return rhl7LogMsg($rc);
882 $ares['document_id'] = $d->get_id();
885 else if ($a[2] == 'CWE') {
886 $ares['result'] = rhl7CWE($a[5], $d2);
888 else if ($a[2] == 'SN') {
889 $ares['result'] = trim(str_replace($d2, ' ', $a[5]));
891 else if ($a[2] == 'TX' || strlen($a[5]) > 200) {
892 // OBX-5 can be a very long string of text with "~" as line separators.
893 // The first line of comments is reserved for such things.
894 $ares['result_data_type'] = 'L';
895 $ares['result'] = '';
896 $ares['comments'] = rhl7Text($a[5]) . $commentdelim;
898 else {
899 $ares['result'] = rhl7Text($a[5]);
901 $ares['result_code' ] = $result_code;
902 $ares['result_text' ] = $result_text;
903 $ares['date' ] = rhl7DateTime($a[14]);
904 $ares['facility' ] = rhl7Text($a[15]);
905 // Ensoftek: Units may have mutiple segments(as seen in MU2 samples), parse and take just first segment.
906 $tmp = explode($d2, $a[6]);
907 $ares['units'] = rhl7Text($tmp[0]);
908 $ares['range' ] = rhl7Text($a[7]);
909 $ares['abnormal' ] = rhl7Abnormal($a[8]); // values are lab dependent
910 $ares['result_status'] = rhl7ReportStatus($a[11]);
912 // Ensoftek: Performing Organization Details. Goes into "Pending Review/Patient Results--->Notes--->Facility" section.
913 $performingOrganization = getPerformingOrganizationDetails($a[23], $a[24], $a[25], $d2, $commentdelim);
914 if (!empty($performingOrganization)) {
915 $ares['facility'] .= $performingOrganization . $commentdelim;
918 /****
919 // Probably need a better way to report this, if it matters.
920 if (!empty($a[19])) {
921 $ares['comments'] .= xl('Analyzed') . ' ' . rhl7DateTime($a[19]) . '.' . $commentdelim;
923 ****/
925 // obxkey is to allow matching this as a parent result.
926 $ares['obxkey'] = $a[3] . $d1 . $a[4];
928 // Append this result to those for the most recent report.
929 // Note the 'procedure_report_id' item is not yet present.
930 $amain[count($amain)-1]['res'][] = $ares;
933 else if ('OBX' == $a[0] && 'MDM' == $msgtype) {
934 $context = $a[0];
935 if ($a[2] == 'TX') {
936 if ($mdm_text !== '') $mdm_text .= "\r\n";
937 $mdm_text .= rhl7Text($a[5]);
939 else {
940 return rhl7LogMsg(xl('Unsupported MDM OBX result type') . ': ' . $a[2]);
944 else if ('ZEF' == $a[0] && 'ORU' == $msgtype) {
945 // ZEF segment is treated like an OBX with an embedded Base64-encoded PDF.
946 $context = 'OBX';
947 $ares = array();
948 $ares['result_data_type'] = 'E';
949 $ares['comments'] = $commentdelim;
951 $fileext = 'pdf';
952 $filename = date("Ymd_His") . '.' . $fileext;
953 $data = rhl7DecodeData('Base64', $a[2]);
954 if ($data === FALSE) return rhl7LogMsg(xl('ZEF segment internal error'));
955 if (!$dryrun) {
956 $d = new Document();
957 $rc = $d->createDocument($porow['patient_id'], $results_category_id, // TBD: Make sure not 0
958 $filename, rhl7MimeType($fileext), $data);
959 if ($rc) return rhl7LogMsg($rc);
960 $ares['document_id'] = $d->get_id();
962 $ares['date'] = $arep['date_report']; // $arep is left over from the OBR logic.
963 // Append this result to those for the most recent report.
964 // Note the 'procedure_report_id' item is not yet present.
965 $amain[count($amain)-1]['res'][] = $ares;
968 else if ('NTE' == $a[0] && 'OBX' == $context && 'ORU' == $msgtype) {
969 // Append this note to the most recent result item's comments.
970 $alast = count($amain) - 1;
971 $rlast = count($amain[$alast]['res']) - 1;
972 $amain[$alast]['res'][$rlast]['comments'] .= rhl7Text($a[3], true) . $commentdelim;
975 // Ensoftek: Get data from SPM segment for specimen.
976 // SPM segment always occurs after the OBX segment.
977 else if ('SPM' == $a[0] && 'ORU' == $msgtype) {
978 rhl7UpdateReportWithSpecimen($amain, $a, $d2);
981 // Add code here for any other segment types that may be present.
983 // Ensoftek: Get data from SPM segment for specimen. Comes in with MU2 samples, but can be ignored.
984 else if ('TQ1' == $a[0] && 'ORU' == $msgtype) {
985 // Ignore and do nothing.
988 else {
989 return rhl7LogMsg(xl('Segment name') . " '${a[0]}' " . xl('is misplaced or unknown'));
993 // Write all reports and their results to the database.
994 // This will do nothing if a dry run or MDM message type.
995 if ('ORU' == $msgtype && !$dryrun) {
996 rhl7FlushMain($amain, $commentdelim);
999 if ('MDM' == $msgtype && !$dryrun) {
1000 // Write documents.
1001 $rc = rhl7FlushMDM($patient_id, $mdm_docname, $mdm_datetime, $mdm_text, $mdm_category_id,
1002 $oprow ? $oprow['username'] : 0);
1003 if ($rc) return rhl7LogMsg($rc);
1006 return $rhl7_return;
1010 * Poll all eligible labs for new results and store them in the database.
1012 * @param array &$info Conveys information to and from the caller:
1013 * FROM THE CALLER:
1014 * $info["$ppid/$filename"]['delete'] = a non-empty value if file deletion is requested.
1015 * $info['select'] = array of patient matching responses where key is serialized patient
1016 * attributes and value is selected pid for this patient, or 0 to create the patient.
1017 * TO THE CALLER:
1018 * $info["$ppid/$filename"]['mssgs'] = array of messages from this function.
1019 * $info['match'] = array of patient matching requests where key is serialized patient
1020 * attributes (ss, fname, lname, DOB) and value is TRUE (irrelevant).
1022 * @return string Error text, or empty if no errors.
1024 function poll_hl7_results(&$info) {
1025 global $srcdir;
1027 // echo "<!-- post: "; print_r($_POST); echo " -->\n"; // debugging
1028 // echo "<!-- in: "; print_r($info); echo " -->\n"; // debugging
1030 $filecount = 0;
1031 $badcount = 0;
1033 if (!isset($info['match' ])) $info['match' ] = array(); // match requests
1034 if (!isset($info['select'])) $info['select'] = array(); // match request responses
1036 $ppres = sqlStatement("SELECT * FROM procedure_providers ORDER BY name");
1038 while ($pprow = sqlFetchArray($ppres)) {
1039 $ppid = $pprow['ppid'];
1040 $protocol = $pprow['protocol'];
1041 $remote_host = $pprow['remote_host'];
1042 $hl7 = '';
1044 if ($protocol == 'SFTP') {
1045 $remote_port = 22;
1046 // Hostname may have ":port" appended to specify a nonstandard port number.
1047 if ($i = strrpos($remote_host, ':')) {
1048 $remote_port = 0 + substr($remote_host, $i + 1);
1049 $remote_host = substr($remote_host, 0, $i);
1051 ini_set('include_path', ini_get('include_path') . PATH_SEPARATOR . "$srcdir/phpseclib");
1052 require_once("$srcdir/phpseclib/Net/SFTP.php");
1053 // Compute the target path name.
1054 $pathname = '.';
1055 if ($pprow['results_path']) $pathname = $pprow['results_path'] . '/' . $pathname;
1056 // Connect to the server and enumerate files to process.
1057 $sftp = new Net_SFTP($remote_host, $remote_port);
1058 if (!$sftp->login($pprow['login'], $pprow['password'])) {
1059 return xl('Login to remote host') . " '$remote_host' " . xl('failed');
1061 $files = $sftp->nlist($pathname);
1062 foreach ($files as $file) {
1063 if (substr($file, 0, 1) == '.') continue;
1064 ++$filecount;
1065 if (!isset($info["$ppid/$file"])) $info["$ppid/$file"] = array();
1066 // Ensure that archive directory exists.
1067 $prpath = $GLOBALS['OE_SITE_DIR'] . "/procedure_results";
1068 if (!file_exists($prpath)) mkdir($prpath);
1069 $prpath .= '/' . $pprow['ppid'];
1070 if (!file_exists($prpath)) mkdir($prpath);
1071 // Get file contents.
1072 $hl7 = $sftp->get("$pathname/$file");
1073 // If user requested reject and delete, do that.
1074 if (!empty($info["$ppid/$file"]['delete'])) {
1075 $fh = fopen("$prpath/$file.rejected", 'w');
1076 if ($fh) {
1077 fwrite($fh, $hl7);
1078 fclose($fh);
1080 else {
1081 return xl('Cannot create file') . ' "' . "$prpath/$file.rejected" . '"';
1083 if (!$sftp->delete("$pathname/$file")) {
1084 return xl('Cannot delete (from SFTP server) file') . ' "' . "$pathname/$file" . '"';
1086 continue;
1088 // Do a dry run of its contents and check for errors and match requests.
1089 $tmp = receive_hl7_results($hl7, $info['match'], $ppid, $pprow['direction'], true, $info['select']);
1090 $info["$ppid/$file"]['mssgs'] = $tmp['mssgs'];
1091 // $info["$ppid/$file"]['match'] = $tmp['match'];
1092 if (!empty($tmp['fatal']) || !empty($tmp['needmatch'])) {
1093 // There are errors or matching requests so skip this file.
1094 continue;
1096 // Now the money shot - not a dry run.
1097 $tmp = receive_hl7_results($hl7, $info['match'], $ppid, $pprow['direction'], false, $info['select']);
1098 $info["$ppid/$file"]['mssgs'] = $tmp['mssgs'];
1099 // $info["$ppid/$file"]['match'] = $tmp['match'];
1100 if (empty($tmp['fatal']) && empty($tmp['needmatch'])) {
1101 // It worked, archive and delete the file.
1102 $fh = fopen("$prpath/$file", 'w');
1103 if ($fh) {
1104 fwrite($fh, $hl7);
1105 fclose($fh);
1107 else {
1108 return xl('Cannot create file') . ' "' . "$prpath/$file" . '"';
1110 if (!$sftp->delete("$pathname/$file")) {
1111 return xl('Cannot delete (from SFTP server) file') . ' "' . "$pathname/$file" . '"';
1114 } // end of this file
1115 } // end SFTP
1117 else if ($protocol == 'FS') {
1118 // Filesystem directory containing results files.
1119 $pathname = $pprow['results_path'];
1120 if (!($dh = opendir($pathname))) {
1121 return xl('Unable to access directory') . " '$pathname'";
1123 // Sort by filename just because.
1124 $files = array();
1125 while (false !== ($file = readdir($dh))) {
1126 if (substr($file, 0, 1) == '.') continue;
1127 $files[$file] = $file;
1129 closedir($dh);
1130 ksort($files);
1131 // For each file...
1132 foreach ($files as $file) {
1133 ++$filecount;
1134 if (!isset($info["$ppid/$file"])) $info["$ppid/$file"] = array();
1135 // Ensure that archive directory exists.
1136 $prpath = $GLOBALS['OE_SITE_DIR'] . "/procedure_results";
1137 if (!file_exists($prpath)) mkdir($prpath);
1138 $prpath .= '/' . $pprow['ppid'];
1139 if (!file_exists($prpath)) mkdir($prpath);
1140 // Get file contents.
1141 $hl7 = file_get_contents("$pathname/$file");
1142 // If user requested reject and delete, do that.
1143 if (!empty($info["$ppid/$file"]['delete'])) {
1144 $fh = fopen("$prpath/$file.rejected", 'w');
1145 if ($fh) {
1146 fwrite($fh, $hl7);
1147 fclose($fh);
1149 else {
1150 return xl('Cannot create file') . ' "' . "$prpath/$file.rejected" . '"';
1152 if (!unlink("$pathname/$file")) {
1153 return xl('Cannot delete file') . ' "' . "$pathname/$file" . '"';
1155 continue;
1157 // Do a dry run of its contents and check for errors and match requests.
1158 $tmp = receive_hl7_results($hl7, $info['match'], $ppid, $pprow['direction'], true, $info['select']);
1159 $info["$ppid/$file"]['mssgs'] = $tmp['mssgs'];
1160 // $info["$ppid/$file"]['match'] = $tmp['match'];
1161 if (!empty($tmp['fatal']) || !empty($tmp['needmatch'])) {
1162 // There are errors or matching requests so skip this file.
1163 continue;
1165 // Now the money shot - not a dry run.
1166 $tmp = receive_hl7_results($hl7, $info['match'], $ppid, $pprow['direction'], false, $info['select']);
1167 $info["$ppid/$file"]['mssgs'] = $tmp['mssgs'];
1168 // $info["$ppid/$file"]['match'] = $tmp['match'];
1169 if (empty($tmp['fatal']) && empty($tmp['needmatch'])) {
1170 // It worked, archive and delete the file.
1171 $fh = fopen("$prpath/$file", 'w');
1172 if ($fh) {
1173 fwrite($fh, $hl7);
1174 fclose($fh);
1176 else {
1177 return xl('Cannot create file') . ' "' . "$prpath/$file" . '"';
1179 if (!unlink("$pathname/$file")) {
1180 return xl('Cannot delete file') . ' "' . "$pathname/$file" . '"';
1183 } // end of this file
1184 } // end FS protocol
1186 // TBD: Insert "else if ($protocol == '???') {...}" to support other protocols.
1188 } // end procedure provider
1190 // echo "<!-- out: "; print_r($info); echo " -->\n"; // debugging
1192 return '';
1194 // PHP end tag omitted intentionally.