ongoing new datepicker project
[openemr.git] / interface / orders / receive_hl7_results.inc.php
blob5e913963943a5f931d3b85202b5872a49bba608d
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");
26 $rhl7_return = array();
28 function rhl7LogMsg($msg, $fatal=true) {
29 global $rhl7_return;
30 if ($fatal) {
31 $rhl7_return['mssgs'][] = '*' . $msg;
32 $rhl7_return['fatal'] = true;
33 newEvent("lab-results-error", $_SESSION['authUser'], $_SESSION['authProvider'], 0, $msg);
35 else {
36 $rhl7_return['mssgs'][] = '>' . $msg;
38 return $rhl7_return;
41 function rhl7InsertRow(&$arr, $tablename) {
42 if (empty($arr)) return;
44 // echo "<!-- "; // debugging
45 // print_r($arr);
46 // echo " -->\n";
48 $query = "INSERT INTO $tablename SET";
49 $binds = array();
50 $sep = '';
51 foreach ($arr as $key => $value) {
52 $query .= "$sep `$key` = ?";
53 $sep = ',';
54 $binds[] = $value;
56 $arr = array();
57 return sqlInsert($query, $binds);
60 // Write all of the accumulated reports and their results.
61 function rhl7FlushMain(&$amain, $commentdelim="\n") {
62 foreach ($amain as $arr) {
63 $procedure_report_id = rhl7InsertRow($arr['rep'], 'procedure_report');
64 foreach ($arr['res'] as $ares) {
65 $ares['procedure_report_id'] = $procedure_report_id;
66 // obxkey was used to identify parent results but is not stored.
67 unset($ares['obxkey']);
68 // If TX result is not over 10 characters, move it from comments to result field.
69 if ($ares['result'] === '' && $ares['result_data_type'] == 'L') {
70 $i = strpos($ares['comments'], $commentdelim);
71 if ($i && $i <= 10) {
72 $ares['result' ] = substr($ares['comments'], 0, $i);
73 $ares['comments'] = substr($ares['comments'], $i);
76 rhl7InsertRow($ares, 'procedure_result');
81 // Write the MDM document if appropriate.
83 function rhl7FlushMDM($patient_id, $mdm_docname, $mdm_datetime, $mdm_text, $mdm_category_id, $provider) {
84 if ($patient_id) {
85 if (!empty($mdm_docname)) $mdm_docname .= '_';
86 $mdm_docname .= preg_replace('/[^0-9]/', '', $mdm_datetime);
87 $filename = $mdm_docname . '.txt';
88 $d = new Document();
89 $rc = $d->createDocument($patient_id, $mdm_category_id, $filename, 'text/plain', $mdm_text);
90 if (!$rc) {
91 rhl7LogMsg(xl('Document created') . ": $filename", false);
92 if ($provider) {
93 $d->postPatientNote($provider, $mdm_category_id, xl('Electronic document received'));
94 rhl7LogMsg(xl('Notification sent to') . ": $provider", false);
96 else {
97 rhl7LogMsg(xl('No provider was matched'), false);
100 return $rc;
102 return '';
105 function rhl7Text($s, $allow_newlines=false) {
106 $s = str_replace('\\S\\' ,'^' , $s);
107 $s = str_replace('\\F\\' ,'|' , $s);
108 $s = str_replace('\\R\\' ,'~' , $s);
109 $s = str_replace('\\T\\' ,'&' , $s);
110 $s = str_replace('\\X0d\\',"\r", $s);
111 $s = str_replace('\\E\\' ,'\\', $s);
112 if ($allow_newlines) {
113 $s = str_replace('\\.br\\',"\n", $s);
115 else {
116 $s = str_replace('\\.br\\','~' , $s);
118 return $s;
121 function rhl7DateTime($s) {
122 // Remove UTC offset if present.
123 if (preg_match('/^([0-9.]+)[+-]/', $s, $tmp)) {
124 $s = $tmp[1];
126 $s = preg_replace('/[^0-9]/', '', $s);
127 if (empty($s)) return '0000-00-00 00:00:00';
128 $ret = substr($s, 0, 4) . '-' . substr($s, 4, 2) . '-' . substr($s, 6, 2);
129 if (strlen($s) > 8) {
130 $ret .= ' ' . substr($s, 8, 2) . ':' . substr($s, 10, 2) . ':';
131 if (strlen($s) > 12) {
132 $ret .= substr($s, 12, 2);
133 } else {
134 $ret .= '00';
137 return $ret;
140 function rhl7DateTimeZone($s) {
141 // UTC offset if present always begins with "+" or "-".
142 if (preg_match('/^[0-9.]+([+-].*)$/', $s, $tmp)) {
143 return trim($tmp[1]);
145 return '';
148 function rhl7Date($s) {
149 return substr(rhl7DateTime($s), 0, 10);
152 function rhl7Abnormal($s) {
153 if ($s == '' ) return 'no';
154 if ($s == 'N' ) return 'no';
155 if ($s == 'A' ) return 'yes';
156 if ($s == 'H' ) return 'high';
157 if ($s == 'L' ) return 'low';
158 if ($s == 'HH') return 'vhigh';
159 if ($s == 'LL') return 'vlow';
160 return rhl7Text($s);
163 function rhl7ReportStatus($s) {
164 if ($s == 'F') return 'final';
165 if ($s == 'P') return 'prelim';
166 if ($s == 'C') return 'correct';
167 if ($s == 'X') return 'error';
168 return rhl7Text($s);
172 * Convert a lower case file extension to a MIME type.
173 * The extension comes from OBX[5][0] which is itself a huge assumption that
174 * the HL7 2.3 standard does not help with. Don't be surprised when we have to
175 * adapt to conventions of various other labs.
177 * @param string $fileext The lower case extension.
178 * @return string MIME type.
180 function rhl7MimeType($fileext) {
181 if ($fileext == 'pdf') return 'application/pdf';
182 if ($fileext == 'doc') return 'application/msword';
183 if ($fileext == 'rtf') return 'application/rtf';
184 if ($fileext == 'txt') return 'text/plain';
185 if ($fileext == 'zip') return 'application/zip';
186 return 'application/octet-stream';
190 * Extract encapsulated document data according to its encoding type.
192 * @param string $enctype Encoding type from OBX[5][3].
193 * @param string &$src Encoded data from OBX[5][4].
194 * @return string Decoded data, or FALSE if error.
196 function rhl7DecodeData($enctype, &$src) {
197 if ($enctype == 'Base64') return base64_decode($src);
198 if ($enctype == 'A' ) return rhl7Text($src);
199 if ($enctype == 'Hex') {
200 $data = '';
201 for ($i = 0; $i < strlen($src) - 1; $i += 2) {
202 $data .= chr(hexdec($src[$i] . $src[$i+1]));
204 return $data;
206 return FALSE;
209 function rhl7CWE($s, $componentdelimiter) {
210 $out = '';
211 if ($s === '') return $out;
212 $arr = explode($componentdelimiter, $s);
213 if (!empty($arr[8])) {
214 $out = $arr[8];
216 else {
217 $out = $arr[0];
218 if (isset($arr[1])) {
219 $out .= " (" . $arr[1] . ")";
222 return $out;
226 * Parse the SPM segment and get the specimen display name and update the table.
228 * @param string $specimen Encoding type from SPM.
230 function rhl7UpdateReportWithSpecimen(&$amain, $specimen, $d2) {
231 $specimen_display = '';
233 // SPM4: Specimen Type: Example: 119297000^BLD^SCT^BldSpc^Blood^99USA^^^Blood Specimen
234 $specimen_display = rhl7CWE($specimen[4], $d2);
236 $tmpnotes = xl('Specimen type') . ': ' . $specimen_display;
237 $tmp = rhl7CWE($specimen[21], $d2);
238 if ($tmp) {
239 $tmpnotes .= '; ' . xl('Rejected') . ': ' . $tmp;
241 $tmp = rhl7CWE($specimen[24], $d2);
242 if ($tmp) {
243 $tmpnotes .= '; ' . xl('Condition') . ': ' . $tmp;
246 $alast = count($amain) - 1;
247 $amain[$alast]['rep']['specimen_num'] = $specimen_display;
248 $amain[$alast]['rep']['report_notes'] .= rhl7Text($tmpnotes) . "\n";
252 * Get the Performing Lab Details from the OBX segment. Mandatory for MU2.
254 * @param string $obx23 Encoding type from OBX23.
255 * @param string $obx23 Encoding type from OBX24.
256 * @param string $obx23 Encoding type from OBX25.
257 * @param string $obx23 New line character.
259 function getPerformingOrganizationDetails($obx23, $obx24, $obx25, $componentdelimiter, $commentdelim) {
260 $s = null;
262 if ( !empty($obx23) || !empty($obx24) || !empty($obx25) )
264 // Organization Name
265 // OBX23 Example: "Century Hospital^^^^^NIST-AA-1&2.16.840.1.113883.3.72.5.30.1&ISO^XX^^^987"
266 $obx23_segs = explode($componentdelimiter, $obx23);
267 if ( !empty($obx23_segs[0]) )
269 $s .= $obx23_segs[0] . $commentdelim;
272 // Medical Director
273 // OBX25 Example: "2343242^Knowsalot^Phil^J.^III^Dr.^^^NIST-AA-1&2.16.840.1.113883.3.72.5.30.1&ISO^L^^^DNSPM"
274 // Dr. Phil Knowsalot J. III
275 if ( !empty($obx25) )
277 $obx25_segs = explode($componentdelimiter, $obx25);
278 $s .= "$obx25_segs[5] $obx25_segs[2] $obx25_segs[1] $obx25_segs[3] $obx25_segs[4]" . $commentdelim;
281 // Organization Address
282 // OBX24 Example: "2070 Test Park^^Los Angeles^CA^90067^USA^B^^06037"
283 if ( !empty($obx24) )
285 $obx24_segs = explode($componentdelimiter, $obx24);
286 //$s .= "$obx24_segs[0] $obx24_segs[1], $obx24_segs[2], $obx24_segs[3], $obx24_segs[4], $obx24_segs[5]" . $commentdelim;
287 $s .= "$obx24_segs[0]$commentdelim$obx24_segs[2], $obx24_segs[3] $obx24_segs[4]$commentdelim$obx24_segs[5]$commentdelim";
288 if ( !empty($obx24_segs[8]) )
290 $s .= "County/Parish Code: $obx24_segs[8]$commentdelim";
294 return $s;
298 * Look for a patient matching the given data.
299 * Return values are:
300 * >0 Definite match, this is the pid.
301 * 0 No patient is close to a match.
302 * -1 It's not clear if there is a match.
304 function match_patient($ptarr) {
305 $in_ss = str_replace('-', '', $ptarr['ss']);
306 $in_fname = $ptarr['fname'];
307 $in_lname = $ptarr['lname'];
308 $in_dob = $ptarr['DOB'];
309 $patient_id = 0;
310 $res = sqlStatement("SELECT pid FROM patient_data WHERE " .
311 "((ss IS NULL OR ss = '' OR '' = ?) AND " .
312 "fname IS NOT NULL AND fname != '' AND fname = ? AND " .
313 "lname IS NOT NULL AND lname != '' AND lname = ? AND " .
314 "DOB IS NOT NULL AND DOB = ?) OR " .
315 "(ss IS NOT NULL AND ss != '' AND REPLACE(ss, '-', '') = ? AND (" .
316 "fname IS NOT NULL AND fname != '' AND fname = ? OR " .
317 "lname IS NOT NULL AND lname != '' AND lname = ? OR " .
318 "DOB IS NOT NULL AND DOB = ?)) " .
319 "ORDER BY ss DESC, pid DESC LIMIT 2",
320 array($in_ss, $in_fname, $in_lname, $in_dob, $in_ss, $in_fname, $in_lname, $in_dob));
321 if (sqlNumRows($res) > 1) {
322 // Multiple matches, so ambiguous.
323 $patient_id = -1;
325 else if (sqlNumRows($res) == 1) {
326 // Got exactly one match, so use it.
327 $tmp = sqlFetchArray($res);
328 $patient_id = intval($tmp['pid']);
330 else {
331 // No match good enough, figure out if there's enough ambiguity to ask the user.
332 $tmp = sqlQuery("SELECT pid FROM patient_data WHERE " .
333 "(ss IS NOT NULL AND ss != '' AND REPLACE(ss, '-', '') = ?) OR " .
334 "(fname IS NOT NULL AND fname != '' AND fname = ? AND " .
335 "lname IS NOT NULL AND lname != '' AND lname = ?) OR " .
336 "(DOB IS NOT NULL AND DOB = ?) " .
337 "LIMIT 1",
338 array($in_ss, $in_fname, $in_lname, $in_dob));
339 if (!empty($tmp['pid'])) {
340 $patient_id = -1;
343 return $patient_id;
347 * Look for a local provider matching the given XCN field from some segment.
349 * @param array $arr array(NPI, lastname, firstname) identifying a provider.
350 * @return mixed Array(id, username), or FALSE if no match.
352 function match_provider($arr) {
353 if (empty($arr)) return false;
354 $op_lname = $op_fname = '';
355 $op_npi = preg_replace('/[^0-9]/', '', $arr[0]);
356 if (!empty($arr[1])) $op_lname = $arr[1];
357 if (!empty($arr[2])) $op_fname = $arr[2];
358 if ($op_npi || ($op_fname && $op_lname)) {
359 if ($op_npi) {
360 if ($op_fname && $op_lname) {
361 $where = "((npi IS NOT NULL AND npi = ?) OR ((npi IS NULL OR npi = ?) AND lname = ? AND fname = ?))";
362 $qarr = array($op_npi, '', $op_lname, $op_fname);
364 else {
365 $where = "npi IS NOT NULL AND npi = ?";
366 $qarr = array($op_npi);
369 else {
370 $where = "lname = ? AND fname = ?";
371 $qarr = array($op_lname, $op_fname);
373 $oprow = sqlQuery("SELECT id, username FROM users WHERE " .
374 "username IS NOT NULL AND username != '' AND $where " .
375 "ORDER BY active DESC, authorized DESC, username, id LIMIT 1",
376 $qarr);
377 if (!empty($oprow)) return $oprow;
379 return false;
383 * Create a patient using whatever patient_data attributes are provided.
385 function create_skeleton_patient($patient_data) {
386 $employer_data = array();
387 $tmp = sqlQuery("SELECT MAX(pid)+1 AS pid FROM patient_data");
388 $ptid = empty($tmp['pid']) ? 1 : intval($tmp['pid']);
389 if (!isset($patient_data['pubpid'])) $patient_data['pubpid'] = $ptid;
390 updatePatientData($ptid, $patient_data, true);
391 updateEmployerData($ptid, $employer_data, true);
392 newHistoryData($ptid);
393 return $ptid;
397 * Parse and save.
399 * @param string &$hl7 The input HL7 text
400 * @param string &$matchreq Array of shared patient matching requests
401 * @param int $lab_id Lab ID
402 * @param char $direction B=Bidirectional, R=Results-only
403 * @param bool $dryrun True = do not update anything, just report errors
404 * @param array $matchresp Array of responses to match requests; key is relative segment number,
405 * value is an existing pid or 0 to specify creating a patient
406 * @return array Array of errors and match requests, if any
408 function receive_hl7_results(&$hl7, &$matchreq, $lab_id=0, $direction='B', $dryrun=false, $matchresp=NULL) {
409 global $rhl7_return;
411 // This will hold returned error messages and related variables.
412 $rhl7_return = array();
413 $rhl7_return['mssgs'] = array();
414 $rhl7_return['needmatch'] = false; // indicates if this file is pending a match request
416 $rhl7_segnum = 0;
418 if (substr($hl7, 0, 3) != 'MSH') {
419 return rhl7LogMsg(xl('Input does not begin with a MSH segment'), true);
422 // This array holds everything to be written to the database.
423 // We save and postpone these writes in case of errors while processing the message,
424 // so we can look up data from parent results when child results are encountered,
425 // and for other logic simplification.
426 // Each element of this array is another array containing the following possible keys:
427 // 'rep' - row of data to write to procedure_report
428 // 'res' - array of rows to write to procedure_result for this procedure_report
429 // 'fid' - unique lab-provided identifier for this report
431 $amain = array();
433 // End-of-line delimiter for text in procedure_result.comments and other multi-line notes.
434 $commentdelim = "\n";
436 // Ensoftek: Different labs seem to send different EOLs. Edit HL7 input to a character we know.
437 $hl7 = (string)str_replace(array("\r\n", "\r", "\n"), "\r", $hl7);
439 $today = time();
441 $in_message_id = '';
442 $in_ssn = '';
443 $in_dob = '';
444 $in_lname = '';
445 $in_fname = '';
446 $in_orderid = 0;
447 $in_procedure_code = '';
448 $in_report_status = '';
449 $in_encounter = 0;
450 $patient_id = 0; // for results-only patient matching logic
451 $porow = false;
452 $pcrow = false;
453 $oprow = false;
455 $code_seq_array = array(); // tracks sequence numbers of order codes
456 $results_category_id = 0; // document category ID for lab results
458 // This is so we know where we are if a segment like NTE that can appear in
459 // different places is encountered.
460 $context = '';
462 // This will be "ORU" or "MDM".
463 $msgtype = '';
465 // Stuff collected for MDM documents.
466 $mdm_datetime = '';
467 $mdm_docname = '';
468 $mdm_text = '';
470 // Delimiters
471 $d0 = "\r";
472 $d1 = substr($hl7, 3, 1); // typically |
473 $d2 = substr($hl7, 4, 1); // typically ^
474 $d3 = substr($hl7, 5, 1); // typically ~
475 $d4 = substr($hl7, 6, 1); // typically \
476 $d5 = substr($hl7, 7, 1); // typically &
478 // We'll need the document category IDs for any embedded documents.
479 $catrow = sqlQuery("SELECT id FROM categories WHERE name = ?",
480 array($GLOBALS['lab_results_category_name']));
481 if (empty($catrow['id'])) {
482 return rhl7LogMsg(xl('Document category for lab results does not exist') .
483 ': ' . $GLOBALS['lab_results_category_name'], true);
485 else {
486 $results_category_id = $catrow['id'];
487 $mdm_category_id = $results_category_id;
488 $catrow = sqlQuery("SELECT id FROM categories WHERE name = ?",
489 array($GLOBALS['gbl_mdm_category_name']));
490 if (!empty($catrow['id'])) $mdm_category_id = $catrow['id'];
493 $segs = explode($d0, $hl7);
495 foreach ($segs as $seg) {
496 if (empty($seg)) continue;
498 // echo "<!-- $dryrun $seg -->\n"; // debugging
500 ++$rhl7_segnum;
501 $a = explode($d1, $seg);
503 if ($a[0] == 'MSH') {
504 if (!$dryrun) {
505 rhl7FlushMain($amain, $commentdelim);
507 $amain = array();
509 if ('MDM' == $msgtype && !$dryrun) {
510 $rc = rhl7FlushMDM($patient_id, $mdm_docname, $mdm_datetime, $mdm_text, $mdm_category_id,
511 $oprow ? $oprow['username'] : 0);
512 if ($rc) return rhl7LogMsg($rc);
513 $patient_id = 0;
516 $context = $a[0];
517 // Ensoftek: Could come is as 'ORU^R01^ORU_R01'. Handle all cases when 'ORU^R01' is seen.
518 if (strstr($a[8], "ORU^R01")) {
519 $msgtype = 'ORU';
521 else if ($a[8] == 'MDM^T02' || $a[8] == 'MDM^T04' || $a[8] == 'MDM^T08') {
522 $msgtype = 'MDM';
523 $mdm_datetime = '';
524 $mdm_docname = '';
525 $mdm_text = '';
527 else {
528 return rhl7LogMsg(xl('MSH.8 message type is not supported') . ": '" . $a[8] . "'", true);
530 $in_message_id = $a[9];
533 else if ($a[0] == 'PID') {
534 $context = $a[0];
536 if ('MDM' == $msgtype && !$dryrun) {
537 $rc = rhl7FlushMDM($patient_id, $mdm_docname, $mdm_datetime, $mdm_text, $mdm_category_id,
538 $oprow ? $oprow['username'] : 0);
539 if ($rc) return rhl7LogMsg($rc);
542 $porow = false;
543 $pcrow = false;
544 $oprow = false;
545 $in_orderid = 0;
546 $in_ssn = preg_replace('/[^0-9]/', '', $a[4]);
547 $in_dob = rhl7Date($a[7]);
548 $tmp = explode($d2, $a[5]);
549 $in_lname = rhl7Text($tmp[0]);
550 $in_fname = rhl7Text($tmp[1]);
551 $in_mname = rhl7Text($tmp[2]);
552 $patient_id = 0;
553 // Patient matching is needed for a results-only interface or MDM message type.
554 if ('R' == $direction || 'MDM' == $msgtype) {
555 $ptarr = array('ss' => strtoupper($in_ss), 'fname' => strtoupper($in_fname),
556 'lname' => strtoupper($in_lname), 'mname' => strtoupper($in_mname),
557 'DOB' => strtoupper($in_dob));
558 $patient_id = match_patient($ptarr);
559 if ($patient_id == -1) {
560 // Result is indeterminate.
561 // Make a stringified form of $ptarr to use as a key.
562 $ptstring = serialize($ptarr);
563 // Check if the user has specified the patient.
564 if (isset($matchresp[$ptstring])) {
565 // This will be an existing pid, or 0 to specify creating a patient.
566 $patient_id = intval($matchresp[$ptstring]);
568 else {
569 if ($dryrun) {
570 // Nope, ask the user to match.
571 $matchreq[$ptstring] = true;
572 $rhl7_return['needmatch'] = true;
574 else {
575 // Should not happen, but it would be bad to abort now. Create the patient.
576 $patient_id = 0;
577 rhl7LogMsg(xl('Unexpected non-match, creating new patient for segment') .
578 ' ' . $rhl7_segnum, false);
582 if ($patient_id == 0 && !$dryrun) {
583 // We must create the patient.
584 $patient_id = create_skeleton_patient($ptarr);
586 if ($patient_id == -1) $patient_id = 0;
587 } // end results-only/MDM logic
590 else if ('PD1' == $a[0]) {
591 // TBD: Save primary care provider name ($a[4]) somewhere?
594 else if ('PV1' == $a[0]) {
595 if ('ORU' == $msgtype) {
596 // Save placer encounter number if present.
597 if ($direction != 'R' && !empty($a[19])) {
598 $tmp = explode($d2, $a[19]);
599 $in_encounter = intval($tmp[0]);
602 else if ('MDM' == $msgtype) {
603 // For documents we want the ordering provider.
604 // Try Referring Provider first.
605 $oprow = match_provider(explode($d2, $a[8]));
606 // If no match, try Other Provider.
607 if (empty($oprow)) $oprow = match_provider(explode($d2, $a[52]));
611 else if ('ORC' == $a[0] && 'ORU' == $msgtype) {
612 $context = $a[0];
613 $arep = array();
614 $porow = false;
615 $pcrow = false;
616 if ($direction != 'R' && $a[2]) $in_orderid = intval($a[2]);
619 else if ('TXA' == $a[0] && 'MDM' == $msgtype) {
620 $context = $a[0];
621 $mdm_datetime = rhl7DateTime($a[4]);
622 $mdm_docname = rhl7Text($a[12]);
625 else if ($a[0] == 'NTE' && ($context == 'ORC' || $context == 'TXA')) {
626 // Is this ever used?
629 else if ('OBR' == $a[0] && 'ORU' == $msgtype) {
630 $context = $a[0];
631 $arep = array();
632 if ($direction != 'R' && $a[2]) {
633 $in_orderid = intval($a[2]);
634 $porow = false;
635 $pcrow = false;
637 $tmp = explode($d2, $a[4]);
638 $in_procedure_code = $tmp[0];
639 $in_procedure_name = $tmp[1];
640 $in_report_status = rhl7ReportStatus($a[25]);
642 // Filler identifier is supposed to be unique for each incoming report.
643 $in_filler_id = $a[3];
644 // Child results will have these pointers to their parent.
645 $in_parent_obrkey = '';
646 $in_parent_obxkey = '';
647 $parent_arep = false; // parent report, if any
648 $parent_ares = false; // parent result, if any
649 if (!empty($a[29])) {
650 // This is a child so there should be a parent.
651 $tmp = explode($d2, $a[29]);
652 $in_parent_obrkey = str_replace($d5, $d2, $tmp[1]);
653 $tmp = explode($d2, $a[26]);
654 $in_parent_obxkey = str_replace($d5, $d2, $tmp[0]) . $d1 . $tmp[1];
655 // Look for the parent report.
656 foreach ($amain as $arr) {
657 if (isset($arr['fid']) && $arr['fid'] == $in_parent_obrkey) {
658 $parent_arep = $arr['rep'];
659 // Now look for the parent result within that report.
660 foreach ($arr['res'] as $tmpres) {
661 if (isset($tmpres['obxkey']) && $tmpres['obxkey'] == $in_parent_obxkey) {
662 $parent_ares = $tmpres;
663 break;
666 break;
671 if ($parent_arep) {
672 $in_orderid = $parent_arep['procedure_order_id'];
675 if ($direction == 'R') {
676 // Save their order ID to procedure_order.control_id.
677 // Look for an existing order using that plus lab_id.
678 // Ordering provider is OBR.16 (NPI^Last^First).
679 // Might not need to create a dummy encounter.
680 // Need also provider_id (probably), patient_id, date_ordered, lab_id.
681 // We have observation date/time in OBR.7.
682 // We have report date/time in OBR.22.
683 // We do not have an order date.
685 $external_order_id = empty($a[2]) ? $a[3] : $a[2];
686 $porow = false;
688 if (!$in_orderid && $external_order_id) {
689 $porow = sqlQuery("SELECT * FROM procedure_order " .
690 "WHERE lab_id = ? AND control_id = ? " .
691 "ORDER BY procedure_order_id DESC LIMIT 1",
692 array($lab_id, $external_order_id));
694 if (!empty($porow)) {
695 $in_orderid = intval($porow['procedure_order_id']);
698 if (!$in_orderid) {
699 // Create order.
700 // Need to identify the ordering provider and, if possible, a recent encounter.
701 $datetime_report = rhl7DateTime($a[22]);
702 $date_report = substr($datetime_report, 0, 10) . ' 00:00:00';
703 $encounter_id = 0;
704 $provider_id = 0;
705 // Look for the most recent encounter within 30 days of the report date.
706 $encrow = sqlQuery("SELECT encounter FROM form_encounter WHERE " .
707 "pid = ? AND date <= ? AND DATE_ADD(date, INTERVAL 30 DAY) > ? " .
708 "ORDER BY date DESC, encounter DESC LIMIT 1",
709 array($patient_id, $date_report, $date_report));
710 if (!empty($encrow)) {
711 $encounter_id = intval($encrow['encounter']);
712 $provider_id = intval($encrow['provider_id']);
714 if (!$provider_id) {
715 // Attempt ordering provider matching by name or NPI.
716 $oprow = match_provider(explode($d2, $a[16]));
717 if (!empty($oprow)) $provider_id = intval($oprow['id']);
719 if (!$dryrun) {
720 // Now create the procedure order.
721 $in_orderid = sqlInsert("INSERT INTO procedure_order SET " .
722 "date_ordered = ?, " .
723 "provider_id = ?, " .
724 "lab_id = ?, " .
725 "date_collected = ?, " .
726 "date_transmitted = ?, " .
727 "patient_id = ?, " .
728 "encounter_id = ?, " .
729 "control_id = ?",
730 array($datetime_report, $provider_id, $lab_id, rhl7DateTime($a[22]),
731 rhl7DateTime($a[7]), $patient_id, $encounter_id, $external_order_id));
732 // If an encounter was identified then link the order to it.
733 if ($encounter_id && $in_orderid) {
734 addForm($encounter_id, "Procedure Order", $in_orderid, "procedure_order", $patient_id);
737 } // end no $porow
738 } // end results-only
739 if (empty($porow)) {
740 $porow = sqlQuery("SELECT * FROM procedure_order WHERE " .
741 "procedure_order_id = ?", array($in_orderid));
742 // The order must already exist. Currently we do not handle electronic
743 // results returned for manual orders.
744 if (empty($porow) && !($dryrun && $direction == 'R')) {
745 return rhl7LogMsg(xl('Procedure order not found') . ": $in_orderid", true);
747 if ($in_encounter) {
748 if ($direction != 'R' && $porow['encounter_id'] != $in_encounter) {
749 return rhl7LogMsg(xl('Encounter ID') .
750 " '" . $porow['encounter_id'] . "' " .
751 xl('for OBR placer order number') .
752 " '$in_orderid' " .
753 xl('does not match the PV1 encounter number') .
754 " '$in_encounter'");
757 else {
758 // They did not return an encounter number to verify, so more checking
759 // might be done here to make sure the patient seems to match.
761 // Save the lab's control ID if there is one.
762 $tmp = explode($d2, $a[3]);
763 $control_id = $tmp[0];
764 if ($control_id && empty($porow['control_id'])) {
765 sqlStatement("UPDATE procedure_order SET control_id = ? WHERE " .
766 "procedure_order_id = ?", array($control_id, $in_orderid));
768 $code_seq_array = array();
770 // Find the order line item (procedure code) that matches this result.
771 // If there is more than one, then we select the one whose sequence number
772 // is next after the last sequence number encountered for this procedure
773 // code; this assumes that result OBRs are returned in the same sequence
774 // as the corresponding OBRs in the order.
775 if (!isset($code_seq_array[$in_procedure_code])) {
776 $code_seq_array[$in_procedure_code] = 0;
778 $pcquery = "SELECT pc.* FROM procedure_order_code AS pc " .
779 "WHERE pc.procedure_order_id = ? AND pc.procedure_code = ? " .
780 "ORDER BY (procedure_order_seq <= ?), procedure_order_seq LIMIT 1";
781 $pcqueryargs = array($in_orderid, $in_procedure_code, $code_seq_array[$in_procedure_code]);
782 $pcrow = sqlQuery($pcquery, $pcqueryargs);
783 if (empty($pcrow)) {
784 // There is no matching procedure in the order, so it must have been
785 // added after the original order was sent, either as a manual request
786 // from the physician or as a "reflex" from the lab.
787 // procedure_source = '2' indicates this.
788 if (!$dryrun) {
790 sqlBeginTrans();
791 $procedure_order_seq = sqlQuery( "SELECT IFNULL(MAX(procedure_order_seq),0) + 1 AS increment FROM procedure_order_code WHERE procedure_order_id = ? ", array($in_orderid));
792 sqlInsert("INSERT INTO procedure_order_code SET " .
793 "procedure_order_id = ?, " .
794 "procedure_order_seq = ?, " .
795 "procedure_code = ?, " .
796 "procedure_name = ?, " .
797 "procedure_source = '2'",
798 array($in_orderid, $procedure_order_seq['increment'], $in_procedure_code, $in_procedure_name));
799 $pcrow = sqlQuery($pcquery, $pcqueryargs);
800 sqlCommitTrans();
802 else {
803 // Dry run, make a dummy procedure_order_code row.
804 $pcrow = array(
805 'procedure_order_id' => $in_orderid,
806 'procedure_order_seq' => 0, // TBD?
810 $code_seq_array[$in_procedure_code] = 0 + $pcrow['procedure_order_seq'];
811 $arep = array();
812 $arep['procedure_order_id'] = $in_orderid;
813 $arep['procedure_order_seq'] = $pcrow['procedure_order_seq'];
814 $arep['date_collected'] = rhl7DateTime($a[7]);
815 $arep['date_collected_tz'] = rhl7DateTimeZone($a[7]);
816 $arep['date_report'] = rhl7DateTime($a[22]);
817 $arep['date_report_tz'] = rhl7DateTimeZone($a[22]);
818 $arep['report_status'] = $in_report_status;
819 $arep['report_notes'] = '';
820 $arep['specimen_num'] = '';
822 // If this is a child report, add some info from the parent.
823 if (!empty($parent_ares)) {
824 $arep['report_notes'] .= xl('This is a child of result') . ' ' .
825 $parent_ares['result_code'] . ' ' . xl('with value') . ' "' .
826 $parent_ares['result'] . '".' . "\n";
828 if (!empty($parent_arep)) {
829 $arep['report_notes'] .= $parent_arep['report_notes'];
830 $arep['specimen_num'] = $parent_arep['specimen_num'];
833 // Create the main array entry for this report and its results.
834 $i = count($amain);
835 $amain[$i] = array();
836 $amain[$i]['rep'] = $arep;
837 $amain[$i]['fid'] = $in_filler_id;
838 $amain[$i]['res'] = array();
841 else if ($a[0] == 'NTE' && $context == 'OBR') {
842 // Append this note to those for the most recent report.
843 $amain[count($amain)-1]['rep']['report_notes'] .= rhl7Text($a[3], true) . "\n";
846 else if ('OBX' == $a[0] && 'ORU' == $msgtype) {
847 $tmp = explode($d2, $a[3]);
848 $result_code = rhl7Text($tmp[0]);
849 $result_text = rhl7Text($tmp[1]);
850 // If this is a text result that duplicates the previous result except
851 // for its value, then treat it as an extension of that result's value.
852 $i = count($amain) - 1;
853 $j = count($amain[$i]['res']) - 1;
854 if ($j >= 0 && $context == 'OBX' && $a[2] == 'TX'
855 && $amain[$i]['res'][$j]['result_data_type'] == 'L'
856 && $amain[$i]['res'][$j]['result_code' ] == $result_code
857 && $amain[$i]['res'][$j]['date' ] == rhl7DateTime($a[14])
858 && $amain[$i]['res'][$j]['facility' ] == rhl7Text($a[15])
859 && $amain[$i]['res'][$j]['abnormal' ] == rhl7Abnormal($a[8])
860 && $amain[$i]['res'][$j]['result_status' ] == rhl7ReportStatus($a[11])
862 $amain[$i]['res'][$j]['comments'] =
863 substr($amain[$i]['res'][$j]['comments'], 0, strlen($amain[$i]['res'][$j]['comments']) - 1) .
864 '~' . rhl7Text($a[5]) . $commentdelim;
865 continue;
867 $context = $a[0];
868 $ares = array();
869 $ares['result_data_type'] = substr($a[2], 0, 1); // N, S, F or E
870 $ares['comments'] = $commentdelim;
871 if ($a[2] == 'ED') {
872 // This is the case of results as an embedded document. We will create
873 // a normal patient document in the assigned category for lab results.
874 $tmp = explode($d2, $a[5]);
875 $fileext = strtolower($tmp[0]);
876 $filename = date("Ymd_His") . '.' . $fileext;
877 $data = rhl7DecodeData($tmp[3], $tmp[4]);
878 if ($data === FALSE) {
879 return rhl7LogMsg(xl('Invalid encapsulated data encoding type') . ': ' . $tmp[3]);
881 if (!$dryrun) {
882 $d = new Document();
883 $rc = $d->createDocument($porow['patient_id'], $results_category_id, // TBD: Make sure not 0
884 $filename, rhl7MimeType($fileext), $data);
885 if ($rc) return rhl7LogMsg($rc);
886 $ares['document_id'] = $d->get_id();
889 else if ($a[2] == 'CWE') {
890 $ares['result'] = rhl7CWE($a[5], $d2);
892 else if ($a[2] == 'SN') {
893 $ares['result'] = trim(str_replace($d2, ' ', $a[5]));
895 else if ($a[2] == 'TX' || strlen($a[5]) > 200) {
896 // OBX-5 can be a very long string of text with "~" as line separators.
897 // The first line of comments is reserved for such things.
898 $ares['result_data_type'] = 'L';
899 $ares['result'] = '';
900 $ares['comments'] = rhl7Text($a[5]) . $commentdelim;
902 else {
903 $ares['result'] = rhl7Text($a[5]);
905 $ares['result_code' ] = $result_code;
906 $ares['result_text' ] = $result_text;
907 $ares['date' ] = rhl7DateTime($a[14]);
908 $ares['facility' ] = rhl7Text($a[15]);
909 // Ensoftek: Units may have mutiple segments(as seen in MU2 samples), parse and take just first segment.
910 $tmp = explode($d2, $a[6]);
911 $ares['units'] = rhl7Text($tmp[0]);
912 $ares['range' ] = rhl7Text($a[7]);
913 $ares['abnormal' ] = rhl7Abnormal($a[8]); // values are lab dependent
914 $ares['result_status'] = rhl7ReportStatus($a[11]);
916 // Ensoftek: Performing Organization Details. Goes into "Pending Review/Patient Results--->Notes--->Facility" section.
917 $performingOrganization = getPerformingOrganizationDetails($a[23], $a[24], $a[25], $d2, $commentdelim);
918 if (!empty($performingOrganization)) {
919 $ares['facility'] .= $performingOrganization . $commentdelim;
922 /****
923 // Probably need a better way to report this, if it matters.
924 if (!empty($a[19])) {
925 $ares['comments'] .= xl('Analyzed') . ' ' . rhl7DateTime($a[19]) . '.' . $commentdelim;
927 ****/
929 // obxkey is to allow matching this as a parent result.
930 $ares['obxkey'] = $a[3] . $d1 . $a[4];
932 // Append this result to those for the most recent report.
933 // Note the 'procedure_report_id' item is not yet present.
934 $amain[count($amain)-1]['res'][] = $ares;
937 else if ('OBX' == $a[0] && 'MDM' == $msgtype) {
938 $context = $a[0];
939 if ($a[2] == 'TX') {
940 if ($mdm_text !== '') $mdm_text .= "\r\n";
941 $mdm_text .= rhl7Text($a[5]);
943 else {
944 return rhl7LogMsg(xl('Unsupported MDM OBX result type') . ': ' . $a[2]);
948 else if ('ZEF' == $a[0] && 'ORU' == $msgtype) {
949 // ZEF segment is treated like an OBX with an embedded Base64-encoded PDF.
950 $context = 'OBX';
951 $ares = array();
952 $ares['result_data_type'] = 'E';
953 $ares['comments'] = $commentdelim;
955 $fileext = 'pdf';
956 $filename = date("Ymd_His") . '.' . $fileext;
957 $data = rhl7DecodeData('Base64', $a[2]);
958 if ($data === FALSE) return rhl7LogMsg(xl('ZEF segment internal error'));
959 if (!$dryrun) {
960 $d = new Document();
961 $rc = $d->createDocument($porow['patient_id'], $results_category_id, // TBD: Make sure not 0
962 $filename, rhl7MimeType($fileext), $data);
963 if ($rc) return rhl7LogMsg($rc);
964 $ares['document_id'] = $d->get_id();
966 $ares['date'] = $arep['date_report']; // $arep is left over from the OBR logic.
967 // Append this result to those for the most recent report.
968 // Note the 'procedure_report_id' item is not yet present.
969 $amain[count($amain)-1]['res'][] = $ares;
972 else if ('NTE' == $a[0] && 'OBX' == $context && 'ORU' == $msgtype) {
973 // Append this note to the most recent result item's comments.
974 $alast = count($amain) - 1;
975 $rlast = count($amain[$alast]['res']) - 1;
976 $amain[$alast]['res'][$rlast]['comments'] .= rhl7Text($a[3], true) . $commentdelim;
979 // Ensoftek: Get data from SPM segment for specimen.
980 // SPM segment always occurs after the OBX segment.
981 else if ('SPM' == $a[0] && 'ORU' == $msgtype) {
982 rhl7UpdateReportWithSpecimen($amain, $a, $d2);
985 // Add code here for any other segment types that may be present.
987 // Ensoftek: Get data from SPM segment for specimen. Comes in with MU2 samples, but can be ignored.
988 else if ('TQ1' == $a[0] && 'ORU' == $msgtype) {
989 // Ignore and do nothing.
992 else {
993 return rhl7LogMsg(xl('Segment name') . " '${a[0]}' " . xl('is misplaced or unknown'));
997 // Write all reports and their results to the database.
998 // This will do nothing if a dry run or MDM message type.
999 if ('ORU' == $msgtype && !$dryrun) {
1000 rhl7FlushMain($amain, $commentdelim);
1003 if ('MDM' == $msgtype && !$dryrun) {
1004 // Write documents.
1005 $rc = rhl7FlushMDM($patient_id, $mdm_docname, $mdm_datetime, $mdm_text, $mdm_category_id,
1006 $oprow ? $oprow['username'] : 0);
1007 if ($rc) return rhl7LogMsg($rc);
1010 return $rhl7_return;
1014 * Poll all eligible labs for new results and store them in the database.
1016 * @param array &$info Conveys information to and from the caller:
1017 * FROM THE CALLER:
1018 * $info["$ppid/$filename"]['delete'] = a non-empty value if file deletion is requested.
1019 * $info['select'] = array of patient matching responses where key is serialized patient
1020 * attributes and value is selected pid for this patient, or 0 to create the patient.
1021 * TO THE CALLER:
1022 * $info["$ppid/$filename"]['mssgs'] = array of messages from this function.
1023 * $info['match'] = array of patient matching requests where key is serialized patient
1024 * attributes (ss, fname, lname, DOB) and value is TRUE (irrelevant).
1026 * @return string Error text, or empty if no errors.
1028 function poll_hl7_results(&$info) {
1029 global $srcdir;
1031 // echo "<!-- post: "; print_r($_POST); echo " -->\n"; // debugging
1032 // echo "<!-- in: "; print_r($info); echo " -->\n"; // debugging
1034 $filecount = 0;
1035 $badcount = 0;
1037 if (!isset($info['match' ])) $info['match' ] = array(); // match requests
1038 if (!isset($info['select'])) $info['select'] = array(); // match request responses
1040 $ppres = sqlStatement("SELECT * FROM procedure_providers ORDER BY name");
1042 while ($pprow = sqlFetchArray($ppres)) {
1043 $ppid = $pprow['ppid'];
1044 $protocol = $pprow['protocol'];
1045 $remote_host = $pprow['remote_host'];
1046 $hl7 = '';
1048 if ($protocol == 'SFTP') {
1049 $remote_port = 22;
1050 // Hostname may have ":port" appended to specify a nonstandard port number.
1051 if ($i = strrpos($remote_host, ':')) {
1052 $remote_port = 0 + substr($remote_host, $i + 1);
1053 $remote_host = substr($remote_host, 0, $i);
1055 // Compute the target path name.
1056 $pathname = '.';
1057 if ($pprow['results_path']) $pathname = $pprow['results_path'] . '/' . $pathname;
1058 // Connect to the server and enumerate files to process.
1059 $sftp = new \phpseclib\Net\SFTP($remote_host, $remote_port);
1060 if (!$sftp->login($pprow['login'], $pprow['password'])) {
1061 return xl('Login to remote host') . " '$remote_host' " . xl('failed');
1063 $files = $sftp->nlist($pathname);
1064 foreach ($files as $file) {
1065 if (substr($file, 0, 1) == '.') continue;
1066 ++$filecount;
1067 if (!isset($info["$ppid/$file"])) $info["$ppid/$file"] = array();
1068 // Ensure that archive directory exists.
1069 $prpath = $GLOBALS['OE_SITE_DIR'] . "/procedure_results";
1070 if (!file_exists($prpath)) mkdir($prpath);
1071 $prpath .= '/' . $pprow['ppid'];
1072 if (!file_exists($prpath)) mkdir($prpath);
1073 // Get file contents.
1074 $hl7 = $sftp->get("$pathname/$file");
1075 // If user requested reject and delete, do that.
1076 if (!empty($info["$ppid/$file"]['delete'])) {
1077 $fh = fopen("$prpath/$file.rejected", 'w');
1078 if ($fh) {
1079 fwrite($fh, $hl7);
1080 fclose($fh);
1082 else {
1083 return xl('Cannot create file') . ' "' . "$prpath/$file.rejected" . '"';
1085 if (!$sftp->delete("$pathname/$file")) {
1086 return xl('Cannot delete (from SFTP server) file') . ' "' . "$pathname/$file" . '"';
1088 continue;
1090 // Do a dry run of its contents and check for errors and match requests.
1091 $tmp = receive_hl7_results($hl7, $info['match'], $ppid, $pprow['direction'], true, $info['select']);
1092 $info["$ppid/$file"]['mssgs'] = $tmp['mssgs'];
1093 // $info["$ppid/$file"]['match'] = $tmp['match'];
1094 if (!empty($tmp['fatal']) || !empty($tmp['needmatch'])) {
1095 // There are errors or matching requests so skip this file.
1096 continue;
1098 // Now the money shot - not a dry run.
1099 $tmp = receive_hl7_results($hl7, $info['match'], $ppid, $pprow['direction'], false, $info['select']);
1100 $info["$ppid/$file"]['mssgs'] = $tmp['mssgs'];
1101 // $info["$ppid/$file"]['match'] = $tmp['match'];
1102 if (empty($tmp['fatal']) && empty($tmp['needmatch'])) {
1103 // It worked, archive and delete the file.
1104 $fh = fopen("$prpath/$file", 'w');
1105 if ($fh) {
1106 fwrite($fh, $hl7);
1107 fclose($fh);
1109 else {
1110 return xl('Cannot create file') . ' "' . "$prpath/$file" . '"';
1112 if (!$sftp->delete("$pathname/$file")) {
1113 return xl('Cannot delete (from SFTP server) file') . ' "' . "$pathname/$file" . '"';
1116 } // end of this file
1117 } // end SFTP
1119 else if ($protocol == 'FS') {
1120 // Filesystem directory containing results files.
1121 $pathname = $pprow['results_path'];
1122 if (!($dh = opendir($pathname))) {
1123 return xl('Unable to access directory') . " '$pathname'";
1125 // Sort by filename just because.
1126 $files = array();
1127 while (false !== ($file = readdir($dh))) {
1128 if (substr($file, 0, 1) == '.') continue;
1129 $files[$file] = $file;
1131 closedir($dh);
1132 ksort($files);
1133 // For each file...
1134 foreach ($files as $file) {
1135 ++$filecount;
1136 if (!isset($info["$ppid/$file"])) $info["$ppid/$file"] = array();
1137 // Ensure that archive directory exists.
1138 $prpath = $GLOBALS['OE_SITE_DIR'] . "/procedure_results";
1139 if (!file_exists($prpath)) mkdir($prpath);
1140 $prpath .= '/' . $pprow['ppid'];
1141 if (!file_exists($prpath)) mkdir($prpath);
1142 // Get file contents.
1143 $hl7 = file_get_contents("$pathname/$file");
1144 // If user requested reject and delete, do that.
1145 if (!empty($info["$ppid/$file"]['delete'])) {
1146 $fh = fopen("$prpath/$file.rejected", 'w');
1147 if ($fh) {
1148 fwrite($fh, $hl7);
1149 fclose($fh);
1151 else {
1152 return xl('Cannot create file') . ' "' . "$prpath/$file.rejected" . '"';
1154 if (!unlink("$pathname/$file")) {
1155 return xl('Cannot delete file') . ' "' . "$pathname/$file" . '"';
1157 continue;
1159 // Do a dry run of its contents and check for errors and match requests.
1160 $tmp = receive_hl7_results($hl7, $info['match'], $ppid, $pprow['direction'], true, $info['select']);
1161 $info["$ppid/$file"]['mssgs'] = $tmp['mssgs'];
1162 // $info["$ppid/$file"]['match'] = $tmp['match'];
1163 if (!empty($tmp['fatal']) || !empty($tmp['needmatch'])) {
1164 // There are errors or matching requests so skip this file.
1165 continue;
1167 // Now the money shot - not a dry run.
1168 $tmp = receive_hl7_results($hl7, $info['match'], $ppid, $pprow['direction'], false, $info['select']);
1169 $info["$ppid/$file"]['mssgs'] = $tmp['mssgs'];
1170 // $info["$ppid/$file"]['match'] = $tmp['match'];
1171 if (empty($tmp['fatal']) && empty($tmp['needmatch'])) {
1172 // It worked, archive and delete the file.
1173 $fh = fopen("$prpath/$file", 'w');
1174 if ($fh) {
1175 fwrite($fh, $hl7);
1176 fclose($fh);
1178 else {
1179 return xl('Cannot create file') . ' "' . "$prpath/$file" . '"';
1181 if (!unlink("$pathname/$file")) {
1182 return xl('Cannot delete file') . ' "' . "$pathname/$file" . '"';
1185 } // end of this file
1186 } // end FS protocol
1188 // TBD: Insert "else if ($protocol == '???') {...}" to support other protocols.
1190 } // end procedure provider
1192 // echo "<!-- out: "; print_r($info); echo " -->\n"; // debugging
1194 return '';
1196 // PHP end tag omitted intentionally.