fix typo
[openemr.git] / interface / orders / receive_hl7_results.inc.php
bloba308b8a185620541bab80dd5d15ed9b4c20039b6
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($obx23) || !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) {
791 sqlBeginTrans();
792 $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));
793 sqlInsert("INSERT INTO procedure_order_code SET " .
794 "procedure_order_id = ?, " .
795 "procedure_order_seq = ?, " .
796 "procedure_code = ?, " .
797 "procedure_name = ?, " .
798 "procedure_source = '2'",
799 array($in_orderid, $procedure_order_seq['increment'], $in_procedure_code, $in_procedure_name));
800 $pcrow = sqlQuery($pcquery, $pcqueryargs);
801 sqlCommitTrans();
803 else {
804 // Dry run, make a dummy procedure_order_code row.
805 $pcrow = array(
806 'procedure_order_id' => $in_orderid,
807 'procedure_order_seq' => 0, // TBD?
811 $code_seq_array[$in_procedure_code] = 0 + $pcrow['procedure_order_seq'];
812 $arep = array();
813 $arep['procedure_order_id'] = $in_orderid;
814 $arep['procedure_order_seq'] = $pcrow['procedure_order_seq'];
815 $arep['date_collected'] = rhl7DateTime($a[7]);
816 $arep['date_collected_tz'] = rhl7DateTimeZone($a[7]);
817 $arep['date_report'] = rhl7DateTime($a[22]);
818 $arep['date_report_tz'] = rhl7DateTimeZone($a[22]);
819 $arep['report_status'] = $in_report_status;
820 $arep['report_notes'] = '';
821 $arep['specimen_num'] = '';
823 // If this is a child report, add some info from the parent.
824 if (!empty($parent_ares)) {
825 $arep['report_notes'] .= xl('This is a child of result') . ' ' .
826 $parent_ares['result_code'] . ' ' . xl('with value') . ' "' .
827 $parent_ares['result'] . '".' . "\n";
829 if (!empty($parent_arep)) {
830 $arep['report_notes'] .= $parent_arep['report_notes'];
831 $arep['specimen_num'] = $parent_arep['specimen_num'];
834 // Create the main array entry for this report and its results.
835 $i = count($amain);
836 $amain[$i] = array();
837 $amain[$i]['rep'] = $arep;
838 $amain[$i]['fid'] = $in_filler_id;
839 $amain[$i]['res'] = array();
842 else if ($a[0] == 'NTE' && $context == 'OBR') {
843 // Append this note to those for the most recent report.
844 $amain[count($amain)-1]['rep']['report_notes'] .= rhl7Text($a[3], true) . "\n";
847 else if ('OBX' == $a[0] && 'ORU' == $msgtype) {
848 $tmp = explode($d2, $a[3]);
849 $result_code = rhl7Text($tmp[0]);
850 $result_text = rhl7Text($tmp[1]);
851 // If this is a text result that duplicates the previous result except
852 // for its value, then treat it as an extension of that result's value.
853 $i = count($amain) - 1;
854 $j = count($amain[$i]['res']) - 1;
855 if ($j >= 0 && $context == 'OBX' && $a[2] == 'TX'
856 && $amain[$i]['res'][$j]['result_data_type'] == 'L'
857 && $amain[$i]['res'][$j]['result_code' ] == $result_code
858 && $amain[$i]['res'][$j]['date' ] == rhl7DateTime($a[14])
859 && $amain[$i]['res'][$j]['facility' ] == rhl7Text($a[15])
860 && $amain[$i]['res'][$j]['abnormal' ] == rhl7Abnormal($a[8])
861 && $amain[$i]['res'][$j]['result_status' ] == rhl7ReportStatus($a[11])
863 $amain[$i]['res'][$j]['comments'] =
864 substr($amain[$i]['res'][$j]['comments'], 0, strlen($amain[$i]['res'][$j]['comments']) - 1) .
865 '~' . rhl7Text($a[5]) . $commentdelim;
866 continue;
868 $context = $a[0];
869 $ares = array();
870 $ares['result_data_type'] = substr($a[2], 0, 1); // N, S, F or E
871 $ares['comments'] = $commentdelim;
872 if ($a[2] == 'ED') {
873 // This is the case of results as an embedded document. We will create
874 // a normal patient document in the assigned category for lab results.
875 $tmp = explode($d2, $a[5]);
876 $fileext = strtolower($tmp[0]);
877 $filename = date("Ymd_His") . '.' . $fileext;
878 $data = rhl7DecodeData($tmp[3], $tmp[4]);
879 if ($data === FALSE) {
880 return rhl7LogMsg(xl('Invalid encapsulated data encoding type') . ': ' . $tmp[3]);
882 if (!$dryrun) {
883 $d = new Document();
884 $rc = $d->createDocument($porow['patient_id'], $results_category_id, // TBD: Make sure not 0
885 $filename, rhl7MimeType($fileext), $data);
886 if ($rc) return rhl7LogMsg($rc);
887 $ares['document_id'] = $d->get_id();
890 else if ($a[2] == 'CWE') {
891 $ares['result'] = rhl7CWE($a[5], $d2);
893 else if ($a[2] == 'SN') {
894 $ares['result'] = trim(str_replace($d2, ' ', $a[5]));
896 else if ($a[2] == 'TX' || strlen($a[5]) > 200) {
897 // OBX-5 can be a very long string of text with "~" as line separators.
898 // The first line of comments is reserved for such things.
899 $ares['result_data_type'] = 'L';
900 $ares['result'] = '';
901 $ares['comments'] = rhl7Text($a[5]) . $commentdelim;
903 else {
904 $ares['result'] = rhl7Text($a[5]);
906 $ares['result_code' ] = $result_code;
907 $ares['result_text' ] = $result_text;
908 $ares['date' ] = rhl7DateTime($a[14]);
909 $ares['facility' ] = rhl7Text($a[15]);
910 // Ensoftek: Units may have mutiple segments(as seen in MU2 samples), parse and take just first segment.
911 $tmp = explode($d2, $a[6]);
912 $ares['units'] = rhl7Text($tmp[0]);
913 $ares['range' ] = rhl7Text($a[7]);
914 $ares['abnormal' ] = rhl7Abnormal($a[8]); // values are lab dependent
915 $ares['result_status'] = rhl7ReportStatus($a[11]);
917 // Ensoftek: Performing Organization Details. Goes into "Pending Review/Patient Results--->Notes--->Facility" section.
918 $performingOrganization = getPerformingOrganizationDetails($a[23], $a[24], $a[25], $d2, $commentdelim);
919 if (!empty($performingOrganization)) {
920 $ares['facility'] .= $performingOrganization . $commentdelim;
923 /****
924 // Probably need a better way to report this, if it matters.
925 if (!empty($a[19])) {
926 $ares['comments'] .= xl('Analyzed') . ' ' . rhl7DateTime($a[19]) . '.' . $commentdelim;
928 ****/
930 // obxkey is to allow matching this as a parent result.
931 $ares['obxkey'] = $a[3] . $d1 . $a[4];
933 // Append this result to those for the most recent report.
934 // Note the 'procedure_report_id' item is not yet present.
935 $amain[count($amain)-1]['res'][] = $ares;
938 else if ('OBX' == $a[0] && 'MDM' == $msgtype) {
939 $context = $a[0];
940 if ($a[2] == 'TX') {
941 if ($mdm_text !== '') $mdm_text .= "\r\n";
942 $mdm_text .= rhl7Text($a[5]);
944 else {
945 return rhl7LogMsg(xl('Unsupported MDM OBX result type') . ': ' . $a[2]);
949 else if ('ZEF' == $a[0] && 'ORU' == $msgtype) {
950 // ZEF segment is treated like an OBX with an embedded Base64-encoded PDF.
951 $context = 'OBX';
952 $ares = array();
953 $ares['result_data_type'] = 'E';
954 $ares['comments'] = $commentdelim;
956 $fileext = 'pdf';
957 $filename = date("Ymd_His") . '.' . $fileext;
958 $data = rhl7DecodeData('Base64', $a[2]);
959 if ($data === FALSE) return rhl7LogMsg(xl('ZEF segment internal error'));
960 if (!$dryrun) {
961 $d = new Document();
962 $rc = $d->createDocument($porow['patient_id'], $results_category_id, // TBD: Make sure not 0
963 $filename, rhl7MimeType($fileext), $data);
964 if ($rc) return rhl7LogMsg($rc);
965 $ares['document_id'] = $d->get_id();
967 $ares['date'] = $arep['date_report']; // $arep is left over from the OBR logic.
968 // Append this result to those for the most recent report.
969 // Note the 'procedure_report_id' item is not yet present.
970 $amain[count($amain)-1]['res'][] = $ares;
973 else if ('NTE' == $a[0] && 'OBX' == $context && 'ORU' == $msgtype) {
974 // Append this note to the most recent result item's comments.
975 $alast = count($amain) - 1;
976 $rlast = count($amain[$alast]['res']) - 1;
977 $amain[$alast]['res'][$rlast]['comments'] .= rhl7Text($a[3], true) . $commentdelim;
980 // Ensoftek: Get data from SPM segment for specimen.
981 // SPM segment always occurs after the OBX segment.
982 else if ('SPM' == $a[0] && 'ORU' == $msgtype) {
983 rhl7UpdateReportWithSpecimen($amain, $a, $d2);
986 // Add code here for any other segment types that may be present.
988 // Ensoftek: Get data from SPM segment for specimen. Comes in with MU2 samples, but can be ignored.
989 else if ('TQ1' == $a[0] && 'ORU' == $msgtype) {
990 // Ignore and do nothing.
993 else {
994 return rhl7LogMsg(xl('Segment name') . " '${a[0]}' " . xl('is misplaced or unknown'));
998 // Write all reports and their results to the database.
999 // This will do nothing if a dry run or MDM message type.
1000 if ('ORU' == $msgtype && !$dryrun) {
1001 rhl7FlushMain($amain, $commentdelim);
1004 if ('MDM' == $msgtype && !$dryrun) {
1005 // Write documents.
1006 $rc = rhl7FlushMDM($patient_id, $mdm_docname, $mdm_datetime, $mdm_text, $mdm_category_id,
1007 $oprow ? $oprow['username'] : 0);
1008 if ($rc) return rhl7LogMsg($rc);
1011 return $rhl7_return;
1015 * Poll all eligible labs for new results and store them in the database.
1017 * @param array &$info Conveys information to and from the caller:
1018 * FROM THE CALLER:
1019 * $info["$ppid/$filename"]['delete'] = a non-empty value if file deletion is requested.
1020 * $info['select'] = array of patient matching responses where key is serialized patient
1021 * attributes and value is selected pid for this patient, or 0 to create the patient.
1022 * TO THE CALLER:
1023 * $info["$ppid/$filename"]['mssgs'] = array of messages from this function.
1024 * $info['match'] = array of patient matching requests where key is serialized patient
1025 * attributes (ss, fname, lname, DOB) and value is TRUE (irrelevant).
1027 * @return string Error text, or empty if no errors.
1029 function poll_hl7_results(&$info) {
1030 global $srcdir;
1032 // echo "<!-- post: "; print_r($_POST); echo " -->\n"; // debugging
1033 // echo "<!-- in: "; print_r($info); echo " -->\n"; // debugging
1035 $filecount = 0;
1036 $badcount = 0;
1038 if (!isset($info['match' ])) $info['match' ] = array(); // match requests
1039 if (!isset($info['select'])) $info['select'] = array(); // match request responses
1041 $ppres = sqlStatement("SELECT * FROM procedure_providers ORDER BY name");
1043 while ($pprow = sqlFetchArray($ppres)) {
1044 $ppid = $pprow['ppid'];
1045 $protocol = $pprow['protocol'];
1046 $remote_host = $pprow['remote_host'];
1047 $hl7 = '';
1049 if ($protocol == 'SFTP') {
1050 $remote_port = 22;
1051 // Hostname may have ":port" appended to specify a nonstandard port number.
1052 if ($i = strrpos($remote_host, ':')) {
1053 $remote_port = 0 + substr($remote_host, $i + 1);
1054 $remote_host = substr($remote_host, 0, $i);
1056 // Compute the target path name.
1057 $pathname = '.';
1058 if ($pprow['results_path']) $pathname = $pprow['results_path'] . '/' . $pathname;
1059 // Connect to the server and enumerate files to process.
1060 $sftp = new Net_SFTP($remote_host, $remote_port);
1061 if (!$sftp->login($pprow['login'], $pprow['password'])) {
1062 return xl('Login to remote host') . " '$remote_host' " . xl('failed');
1064 $files = $sftp->nlist($pathname);
1065 foreach ($files as $file) {
1066 if (substr($file, 0, 1) == '.') continue;
1067 ++$filecount;
1068 if (!isset($info["$ppid/$file"])) $info["$ppid/$file"] = array();
1069 // Ensure that archive directory exists.
1070 $prpath = $GLOBALS['OE_SITE_DIR'] . "/procedure_results";
1071 if (!file_exists($prpath)) mkdir($prpath);
1072 $prpath .= '/' . $pprow['ppid'];
1073 if (!file_exists($prpath)) mkdir($prpath);
1074 // Get file contents.
1075 $hl7 = $sftp->get("$pathname/$file");
1076 // If user requested reject and delete, do that.
1077 if (!empty($info["$ppid/$file"]['delete'])) {
1078 $fh = fopen("$prpath/$file.rejected", 'w');
1079 if ($fh) {
1080 fwrite($fh, $hl7);
1081 fclose($fh);
1083 else {
1084 return xl('Cannot create file') . ' "' . "$prpath/$file.rejected" . '"';
1086 if (!$sftp->delete("$pathname/$file")) {
1087 return xl('Cannot delete (from SFTP server) file') . ' "' . "$pathname/$file" . '"';
1089 continue;
1091 // Do a dry run of its contents and check for errors and match requests.
1092 $tmp = receive_hl7_results($hl7, $info['match'], $ppid, $pprow['direction'], true, $info['select']);
1093 $info["$ppid/$file"]['mssgs'] = $tmp['mssgs'];
1094 // $info["$ppid/$file"]['match'] = $tmp['match'];
1095 if (!empty($tmp['fatal']) || !empty($tmp['needmatch'])) {
1096 // There are errors or matching requests so skip this file.
1097 continue;
1099 // Now the money shot - not a dry run.
1100 $tmp = receive_hl7_results($hl7, $info['match'], $ppid, $pprow['direction'], false, $info['select']);
1101 $info["$ppid/$file"]['mssgs'] = $tmp['mssgs'];
1102 // $info["$ppid/$file"]['match'] = $tmp['match'];
1103 if (empty($tmp['fatal']) && empty($tmp['needmatch'])) {
1104 // It worked, archive and delete the file.
1105 $fh = fopen("$prpath/$file", 'w');
1106 if ($fh) {
1107 fwrite($fh, $hl7);
1108 fclose($fh);
1110 else {
1111 return xl('Cannot create file') . ' "' . "$prpath/$file" . '"';
1113 if (!$sftp->delete("$pathname/$file")) {
1114 return xl('Cannot delete (from SFTP server) file') . ' "' . "$pathname/$file" . '"';
1117 } // end of this file
1118 } // end SFTP
1120 else if ($protocol == 'FS') {
1121 // Filesystem directory containing results files.
1122 $pathname = $pprow['results_path'];
1123 if (!($dh = opendir($pathname))) {
1124 return xl('Unable to access directory') . " '$pathname'";
1126 // Sort by filename just because.
1127 $files = array();
1128 while (false !== ($file = readdir($dh))) {
1129 if (substr($file, 0, 1) == '.') continue;
1130 $files[$file] = $file;
1132 closedir($dh);
1133 ksort($files);
1134 // For each file...
1135 foreach ($files as $file) {
1136 ++$filecount;
1137 if (!isset($info["$ppid/$file"])) $info["$ppid/$file"] = array();
1138 // Ensure that archive directory exists.
1139 $prpath = $GLOBALS['OE_SITE_DIR'] . "/procedure_results";
1140 if (!file_exists($prpath)) mkdir($prpath);
1141 $prpath .= '/' . $pprow['ppid'];
1142 if (!file_exists($prpath)) mkdir($prpath);
1143 // Get file contents.
1144 $hl7 = file_get_contents("$pathname/$file");
1145 // If user requested reject and delete, do that.
1146 if (!empty($info["$ppid/$file"]['delete'])) {
1147 $fh = fopen("$prpath/$file.rejected", 'w');
1148 if ($fh) {
1149 fwrite($fh, $hl7);
1150 fclose($fh);
1152 else {
1153 return xl('Cannot create file') . ' "' . "$prpath/$file.rejected" . '"';
1155 if (!unlink("$pathname/$file")) {
1156 return xl('Cannot delete file') . ' "' . "$pathname/$file" . '"';
1158 continue;
1160 // Do a dry run of its contents and check for errors and match requests.
1161 $tmp = receive_hl7_results($hl7, $info['match'], $ppid, $pprow['direction'], true, $info['select']);
1162 $info["$ppid/$file"]['mssgs'] = $tmp['mssgs'];
1163 // $info["$ppid/$file"]['match'] = $tmp['match'];
1164 if (!empty($tmp['fatal']) || !empty($tmp['needmatch'])) {
1165 // There are errors or matching requests so skip this file.
1166 continue;
1168 // Now the money shot - not a dry run.
1169 $tmp = receive_hl7_results($hl7, $info['match'], $ppid, $pprow['direction'], false, $info['select']);
1170 $info["$ppid/$file"]['mssgs'] = $tmp['mssgs'];
1171 // $info["$ppid/$file"]['match'] = $tmp['match'];
1172 if (empty($tmp['fatal']) && empty($tmp['needmatch'])) {
1173 // It worked, archive and delete the file.
1174 $fh = fopen("$prpath/$file", 'w');
1175 if ($fh) {
1176 fwrite($fh, $hl7);
1177 fclose($fh);
1179 else {
1180 return xl('Cannot create file') . ' "' . "$prpath/$file" . '"';
1182 if (!unlink("$pathname/$file")) {
1183 return xl('Cannot delete file') . ' "' . "$pathname/$file" . '"';
1186 } // end of this file
1187 } // end FS protocol
1189 // TBD: Insert "else if ($protocol == '???') {...}" to support other protocols.
1191 } // end procedure provider
1193 // echo "<!-- out: "; print_r($info); echo " -->\n"; // debugging
1195 return '';
1197 // PHP end tag omitted intentionally.