3 * Functions to support parsing and saving hl7 results.
5 * Copyright (C) 2013-2015 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>.
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) {
32 $rhl7_return['mssgs'][] = '*' . $msg;
33 $rhl7_return['fatal'] = true;
34 newEvent("lab-results-error", $_SESSION['authUser'], $_SESSION['authProvider'], 0, $msg);
37 $rhl7_return['mssgs'][] = '>' . $msg;
42 function rhl7InsertRow(&$arr, $tablename) {
43 if (empty($arr)) return;
45 // echo "<!-- "; // debugging
49 $query = "INSERT INTO $tablename SET";
52 foreach ($arr as $key => $value) {
53 $query .= "$sep `$key` = ?";
58 return sqlInsert($query, $binds);
61 // Write all of the accumulated reports and their results.
62 function rhl7FlushMain(&$amain) {
63 // echo "<!--\n"; // debugging
64 // print_r($amain); // debugging
65 // echo " -->\n"; // debugging
67 foreach ($amain as $arr) {
68 $procedure_report_id = rhl7InsertRow($arr['rep'], 'procedure_report');
69 foreach ($arr['res'] as $ares) {
70 $ares['procedure_report_id'] = $procedure_report_id;
71 // obxkey was used to identify parent results but is not stored.
72 unset($ares['obxkey']);
73 rhl7InsertRow($ares, 'procedure_result');
78 // Write the MDM document if appropriate.
80 function rhl7FlushMDM($patient_id, $mdm_docname, $mdm_datetime, $mdm_text, $mdm_category_id, $provider) {
82 if (!empty($mdm_docname)) $mdm_docname .= '_';
83 $mdm_docname .= preg_replace('/[^0-9]/', '', $mdm_datetime);
84 $filename = $mdm_docname . '.txt';
86 $rc = $d->createDocument($patient_id, $mdm_category_id, $filename, 'text/plain', $mdm_text);
88 rhl7LogMsg(xl('Document created') . ": $filename", false);
90 $d->postPatientNote($provider, $mdm_category_id, xl('Electronic document received'));
91 rhl7LogMsg(xl('Notification sent to') . ": $provider", false);
94 rhl7LogMsg(xl('No provider was matched'), false);
102 function rhl7Text($s) {
103 $s = str_replace('\\S\\' ,'^' , $s);
104 $s = str_replace('\\F\\' ,'|' , $s);
105 $s = str_replace('\\R\\' ,'~' , $s);
106 $s = str_replace('\\T\\' ,'&' , $s);
107 $s = str_replace('\\X0d\\',"\r", $s);
108 $s = str_replace('\\E\\' ,'\\', $s);
112 function rhl7DateTime($s) {
113 $s = preg_replace('/[^0-9]/', '', $s);
114 if (empty($s)) return '0000-00-00 00:00:00';
115 $ret = substr($s, 0, 4) . '-' . substr($s, 4, 2) . '-' . substr($s, 6, 2);
116 if (strlen($s) > 8) {
117 $ret .= ' ' . substr($s, 8, 2) . ':' . substr($s, 10, 2) . ':';
118 if (strlen($s) > 12) {
119 $ret .= substr($s, 12, 2);
127 function rhl7Date($s) {
128 return substr(rhl7DateTime($s), 0, 10);
131 function rhl7Abnormal($s) {
132 if ($s == '' ) return 'no';
133 if ($s == 'N' ) return 'no';
134 if ($s == 'A' ) return 'yes';
135 if ($s == 'H' ) return 'high';
136 if ($s == 'L' ) return 'low';
137 if ($s == 'HH') return 'vhigh';
138 if ($s == 'LL') return 'vlow';
142 function rhl7ReportStatus($s) {
143 if ($s == 'F') return 'final';
144 if ($s == 'P') return 'prelim';
145 if ($s == 'C') return 'correct';
146 if ($s == 'X') return 'error';
151 * Convert a lower case file extension to a MIME type.
152 * The extension comes from OBX[5][0] which is itself a huge assumption that
153 * the HL7 2.3 standard does not help with. Don't be surprised when we have to
154 * adapt to conventions of various other labs.
156 * @param string $fileext The lower case extension.
157 * @return string MIME type.
159 function rhl7MimeType($fileext) {
160 if ($fileext == 'pdf') return 'application/pdf';
161 if ($fileext == 'doc') return 'application/msword';
162 if ($fileext == 'rtf') return 'application/rtf';
163 if ($fileext == 'txt') return 'text/plain';
164 if ($fileext == 'zip') return 'application/zip';
165 return 'application/octet-stream';
169 * Extract encapsulated document data according to its encoding type.
171 * @param string $enctype Encoding type from OBX[5][3].
172 * @param string &$src Encoded data from OBX[5][4].
173 * @return string Decoded data, or FALSE if error.
175 function rhl7DecodeData($enctype, &$src) {
176 if ($enctype == 'Base64') return base64_decode($src);
177 if ($enctype == 'A' ) return rhl7Text($src);
178 if ($enctype == 'Hex') {
180 for ($i = 0; $i < strlen($src) - 1; $i +
= 2) {
181 $data .= chr(hexdec($src[$i] . $src[$i+
1]));
188 function rhl7CWE($s, $componentdelimiter) {
190 if ($s === '') return $out;
191 $arr = explode($componentdelimiter, $s);
192 if (!empty($arr[8])) {
197 if (isset($arr[1])) {
198 $out .= " (" . $arr[1] . ")";
205 * Parse the SPM segment and get the specimen display name and update the table.
207 * @param string $specimen Encoding type from SPM.
209 function rhl7UpdateReportWithSpecimen(&$amain, $specimen, $d2) {
210 $specimen_display = '';
212 // SPM4: Specimen Type: Example: 119297000^BLD^SCT^BldSpc^Blood^99USA^^^Blood Specimen
213 $specimen_display = rhl7CWE($specimen[4], $d2);
215 $tmpnotes = xl('Specimen type') . ': ' . $specimen_display;
216 $tmp = rhl7CWE($specimen[21], $d2);
218 $tmpnotes .= '; ' . xl('Rejected') . ': ' . $tmp;
220 $tmp = rhl7CWE($specimen[24], $d2);
222 $tmpnotes .= '; ' . xl('Condition') . ': ' . $tmp;
225 $alast = count($amain) - 1;
226 $amain[$alast]['rep']['specimen_num'] = $specimen_display;
227 $amain[$alast]['rep']['report_notes'] .= rhl7Text($tmpnotes) . "\n";
231 * Get the Performing Lab Details from the OBX segment. Mandatory for MU2.
233 * @param string $obx23 Encoding type from OBX23.
234 * @param string $obx23 Encoding type from OBX24.
235 * @param string $obx23 Encoding type from OBX25.
236 * @param string $obx23 New line character.
238 function getPerformingOrganizationDetails($obx23, $obx24, $obx25, $componentdelimiter, $commentdelim) {
241 if ( !empty($obx24) ||
!empty($obx24) ||
!empty($obx25) )
244 // OBX23 Example: "Century Hospital^^^^^NIST-AA-1&2.16.840.1.113883.3.72.5.30.1&ISO^XX^^^987"
245 $obx23_segs = explode($componentdelimiter, $obx23);
246 if ( !empty($obx23_segs[0]) )
248 $s .= $obx23_segs[0] . $commentdelim;
252 // OBX25 Example: "2343242^Knowsalot^Phil^J.^III^Dr.^^^NIST-AA-1&2.16.840.1.113883.3.72.5.30.1&ISO^L^^^DNSPM"
253 // Dr. Phil Knowsalot J. III
254 if ( !empty($obx25) )
256 $obx25_segs = explode($componentdelimiter, $obx25);
257 $s .= "$obx25_segs[5] $obx25_segs[2] $obx25_segs[1] $obx25_segs[3] $obx25_segs[4]" . $commentdelim;
260 // Organization Address
261 // OBX24 Example: "2070 Test Park^^Los Angeles^CA^90067^USA^B^^06037"
262 if ( !empty($obx24) )
264 $obx24_segs = explode($componentdelimiter, $obx24);
265 //$s .= "$obx24_segs[0] $obx24_segs[1], $obx24_segs[2], $obx24_segs[3], $obx24_segs[4], $obx24_segs[5]" . $commentdelim;
266 $s .= "$obx24_segs[0]$commentdelim$obx24_segs[2], $obx24_segs[3] $obx24_segs[4]$commentdelim$obx24_segs[5]$commentdelim";
267 if ( !empty($obx24_segs[8]) )
269 $s .= "County/Parish Code: $obx24_segs[8]$commentdelim";
277 * Look for a patient matching the given data.
279 * >0 Definite match, this is the pid.
280 * 0 No patient is close to a match.
281 * -1 It's not clear if there is a match.
283 function match_patient($ptarr) {
284 $in_ss = str_replace('-', '', $ptarr['ss']);
285 $in_fname = $ptarr['fname'];
286 $in_lname = $ptarr['lname'];
287 $in_dob = $ptarr['DOB'];
289 $res = sqlStatement("SELECT pid FROM patient_data WHERE " .
290 "((ss IS NULL OR ss = '' OR '' = ?) AND " .
291 "fname IS NOT NULL AND fname != '' AND fname = ? AND " .
292 "lname IS NOT NULL AND lname != '' AND lname = ? AND " .
293 "DOB IS NOT NULL AND DOB = ?) OR " .
294 "(ss IS NOT NULL AND ss != '' AND REPLACE(ss, '-', '') = ? AND (" .
295 "fname IS NOT NULL AND fname != '' AND fname = ? OR " .
296 "lname IS NOT NULL AND lname != '' AND lname = ? OR " .
297 "DOB IS NOT NULL AND DOB = ?)) " .
298 "ORDER BY ss DESC, pid DESC LIMIT 2",
299 array($in_ss, $in_fname, $in_lname, $in_dob, $in_ss, $in_fname, $in_lname, $in_dob));
300 if (sqlNumRows($res) > 1) {
301 // Multiple matches, so ambiguous.
304 else if (sqlNumRows($res) == 1) {
305 // Got exactly one match, so use it.
306 $tmp = sqlFetchArray($res);
307 $patient_id = intval($tmp['pid']);
310 // No match good enough, figure out if there's enough ambiguity to ask the user.
311 $tmp = sqlQuery("SELECT pid FROM patient_data WHERE " .
312 "(ss IS NOT NULL AND ss != '' AND REPLACE(ss, '-', '') = ?) OR " .
313 "(fname IS NOT NULL AND fname != '' AND fname = ? AND " .
314 "lname IS NOT NULL AND lname != '' AND lname = ?) OR " .
315 "(DOB IS NOT NULL AND DOB = ?) " .
317 array($in_ss, $in_fname, $in_lname, $in_dob));
318 if (!empty($tmp['pid'])) {
326 * Look for a local provider matching the given XCN field from some segment.
328 * @param array $arr array(NPI, lastname, firstname) identifying a provider.
329 * @return mixed Array(id, username), or FALSE if no match.
331 function match_provider($arr) {
332 if (empty($arr)) return false;
333 $op_lname = $op_fname = '';
334 $op_npi = preg_replace('/[^0-9]/', '', $arr[0]);
335 if (!empty($arr[1])) $op_lname = $arr[1];
336 if (!empty($arr[2])) $op_fname = $arr[2];
337 if ($op_npi ||
($op_fname && $op_lname)) {
339 if ($op_fname && $op_lname) {
340 $where = "((npi IS NOT NULL AND npi = ?) OR ((npi IS NULL OR npi = ?) AND lname = ? AND fname = ?))";
341 $qarr = array($op_npi, '', $op_lname, $op_fname);
344 $where = "npi IS NOT NULL AND npi = ?";
345 $qarr = array($op_npi);
349 $where = "lname = ? AND fname = ?";
350 $qarr = array($op_lname, $op_fname);
352 $oprow = sqlQuery("SELECT id, username FROM users WHERE " .
353 "username IS NOT NULL AND username != '' AND $where " .
354 "ORDER BY active DESC, authorized DESC, username, id LIMIT 1",
356 if (!empty($oprow)) return $oprow;
362 * Create a patient using whatever patient_data attributes are provided.
364 function create_skeleton_patient($patient_data) {
365 $employer_data = array();
366 $tmp = sqlQuery("SELECT MAX(pid)+1 AS pid FROM patient_data");
367 $ptid = empty($tmp['pid']) ?
1 : intval($tmp['pid']);
368 if (!isset($patient_data['pubpid'])) $patient_data['pubpid'] = $ptid;
369 updatePatientData($ptid, $patient_data, true);
370 updateEmployerData($ptid, $employer_data, true);
371 newHistoryData($ptid);
378 * @param string &$hl7 The input HL7 text
379 * @param string &$matchreq Array of shared patient matching requests
380 * @param int $lab_id Lab ID
381 * @param char $direction B=Bidirectional, R=Results-only
382 * @param bool $dryrun True = do not update anything, just report errors
383 * @param array $matchresp Array of responses to match requests; key is relative segment number,
384 * value is an existing pid or 0 to specify creating a patient
385 * @return array Array of errors and match requests, if any
387 function receive_hl7_results(&$hl7, &$matchreq, $lab_id=0, $direction='B', $dryrun=false, $matchresp=NULL) {
390 // This will hold returned error messages and related variables.
391 $rhl7_return = array();
392 $rhl7_return['mssgs'] = array();
393 $rhl7_return['needmatch'] = false; // indicates if this file is pending a match request
397 if (substr($hl7, 0, 3) != 'MSH') {
398 return rhl7LogMsg(xl('Input does not begin with a MSH segment'), true);
401 // This array holds everything to be written to the database.
402 // We save and postpone these writes in case of errors while processing the message,
403 // so we can look up data from parent results when child results are encountered,
404 // and for other logic simplification.
405 // Each element of this array is another array containing the following possible keys:
406 // 'rep' - row of data to write to procedure_report
407 // 'res' - array of rows to write to procedure_result for this procedure_report
408 // 'fid' - unique lab-provided identifier for this report
412 // End-of-line delimiter for text in procedure_result.comments and other multi-line notes.
413 $commentdelim = "\n";
415 // Ensoftek: Different labs seem to send different EOLs. Edit HL7 input to a character we know.
416 $hl7 = (string)str_replace(array("\r\n", "\r", "\n"), "\r", $hl7);
426 $in_procedure_code = '';
427 $in_report_status = '';
429 $patient_id = 0; // for results-only patient matching logic
434 $code_seq_array = array(); // tracks sequence numbers of order codes
435 $results_category_id = 0; // document category ID for lab results
437 // This is so we know where we are if a segment like NTE that can appear in
438 // different places is encountered.
441 // This will be "ORU" or "MDM".
444 // Stuff collected for MDM documents.
451 $d1 = substr($hl7, 3, 1); // typically |
452 $d2 = substr($hl7, 4, 1); // typically ^
453 $d3 = substr($hl7, 5, 1); // typically ~
454 $d4 = substr($hl7, 6, 1); // typically \
455 $d5 = substr($hl7, 7, 1); // typically &
457 // We'll need the document category IDs for any embedded documents.
458 $catrow = sqlQuery("SELECT id FROM categories WHERE name = ?",
459 array($GLOBALS['lab_results_category_name']));
460 if (empty($catrow['id'])) {
461 return rhl7LogMsg(xl('Document category for lab results does not exist') .
462 ': ' . $GLOBALS['lab_results_category_name'], true);
465 $results_category_id = $catrow['id'];
466 $mdm_category_id = $results_category_id;
467 $catrow = sqlQuery("SELECT id FROM categories WHERE name = ?",
468 array($GLOBALS['gbl_mdm_category_name']));
469 if (!empty($catrow['id'])) $mdm_category_id = $catrow['id'];
472 $segs = explode($d0, $hl7);
474 foreach ($segs as $seg) {
475 if (empty($seg)) continue;
477 // echo "<!-- $dryrun $seg -->\n"; // debugging
480 $a = explode($d1, $seg);
482 if ($a[0] == 'MSH') {
484 rhl7FlushMain($amain);
488 if ('MDM' == $msgtype && !$dryrun) {
489 $rc = rhl7FlushMDM($patient_id, $mdm_docname, $mdm_datetime, $mdm_text, $mdm_category_id,
490 $oprow ?
$oprow['username'] : 0);
491 if ($rc) return rhl7LogMsg($rc);
496 // Ensoftek: Could come is as 'ORU^R01^ORU_R01'. Handle all cases when 'ORU^R01' is seen.
497 if (strstr($a[8], "ORU^R01")) {
500 else if ($a[8] == 'MDM^T02' ||
$a[8] == 'MDM^T04' ||
$a[8] == 'MDM^T08') {
507 return rhl7LogMsg(xl('MSH.8 message type is not supported') . ": '" . $a[8] . "'", true);
509 $in_message_id = $a[9];
512 else if ($a[0] == 'PID') {
515 if ('MDM' == $msgtype && !$dryrun) {
516 $rc = rhl7FlushMDM($patient_id, $mdm_docname, $mdm_datetime, $mdm_text, $mdm_category_id,
517 $oprow ?
$oprow['username'] : 0);
518 if ($rc) return rhl7LogMsg($rc);
525 $in_ssn = preg_replace('/[^0-9]/', '', $a[4]);
526 $in_dob = rhl7Date($a[7]);
527 $tmp = explode($d2, $a[5]);
528 $in_lname = rhl7Text($tmp[0]);
529 $in_fname = rhl7Text($tmp[1]);
530 $in_mname = rhl7Text($tmp[2]);
532 // Patient matching is needed for a results-only interface or MDM message type.
533 if ('R' == $direction ||
'MDM' == $msgtype) {
534 $ptarr = array('ss' => strtoupper($in_ss), 'fname' => strtoupper($in_fname),
535 'lname' => strtoupper($in_lname), 'mname' => strtoupper($in_mname),
536 'DOB' => strtoupper($in_dob));
537 $patient_id = match_patient($ptarr);
538 if ($patient_id == -1) {
539 // Result is indeterminate.
540 // Make a stringified form of $ptarr to use as a key.
541 $ptstring = serialize($ptarr);
542 // Check if the user has specified the patient.
543 if (isset($matchresp[$ptstring])) {
544 // This will be an existing pid, or 0 to specify creating a patient.
545 $patient_id = intval($matchresp[$ptstring]);
549 // Nope, ask the user to match.
550 $matchreq[$ptstring] = true;
551 $rhl7_return['needmatch'] = true;
554 // Should not happen, but it would be bad to abort now. Create the patient.
556 rhl7LogMsg(xl('Unexpected non-match, creating new patient for segment') .
557 ' ' . $rhl7_segnum, false);
561 if ($patient_id == 0 && !$dryrun) {
562 // We must create the patient.
563 $patient_id = create_skeleton_patient($ptarr);
565 if ($patient_id == -1) $patient_id = 0;
566 } // end results-only/MDM logic
569 else if ('PV1' == $a[0]) {
570 if ('ORU' == $msgtype) {
571 // Save placer encounter number if present.
572 if ($direction != 'R' && !empty($a[19])) {
573 $tmp = explode($d2, $a[19]);
574 $in_encounter = intval($tmp[0]);
577 else if ('MDM' == $msgtype) {
578 // For documents we want the ordering provider.
579 // Try Referring Provider first.
580 $oprow = match_provider(explode($d2, $a[8]));
581 // If no match, try Other Provider.
582 if (empty($oprow)) $oprow = match_provider(explode($d2, $a[52]));
586 else if ('ORC' == $a[0] && 'ORU' == $msgtype) {
592 if ($direction != 'R' && $a[2]) $in_orderid = intval($a[2]);
595 else if ('TXA' == $a[0] && 'MDM' == $msgtype) {
597 $mdm_datetime = rhl7DateTime($a[4]);
598 $mdm_docname = rhl7Text($a[12]);
601 else if ($a[0] == 'NTE' && ($context == 'ORC' ||
$context == 'TXA')) {
602 // Is this ever used?
605 else if ('OBR' == $a[0] && 'ORU' == $msgtype) {
608 if ($direction != 'R' && $a[2]) {
609 $in_orderid = intval($a[2]);
613 $tmp = explode($d2, $a[4]);
614 $in_procedure_code = $tmp[0];
615 $in_procedure_name = $tmp[1];
616 $in_report_status = rhl7ReportStatus($a[25]);
618 // Filler identifier is supposed to be unique for each incoming report.
619 $in_filler_id = $a[3];
620 // Child results will have these pointers to their parent.
621 $in_parent_obrkey = '';
622 $in_parent_obxkey = '';
623 $parent_arep = false; // parent report, if any
624 $parent_ares = false; // parent result, if any
625 if (!empty($a[29])) {
626 // This is a child so there should be a parent.
627 $tmp = explode($d2, $a[29]);
628 $in_parent_obrkey = str_replace($d5, $d2, $tmp[1]);
629 $tmp = explode($d2, $a[26]);
630 $in_parent_obxkey = str_replace($d5, $d2, $tmp[0]) . $d1 . $tmp[1];
631 // Look for the parent report.
632 foreach ($amain as $arr) {
633 if (isset($arr['fid']) && $arr['fid'] == $in_parent_obrkey) {
634 $parent_arep = $arr['rep'];
635 // Now look for the parent result within that report.
636 foreach ($arr['res'] as $tmpres) {
637 if (isset($tmpres['obxkey']) && $tmpres['obxkey'] == $in_parent_obxkey) {
638 $parent_ares = $tmpres;
648 $in_orderid = $parent_arep['procedure_order_id'];
651 if ($direction == 'R') {
652 // Save their order ID to procedure_order.control_id.
653 // Look for an existing order using that plus lab_id.
654 // Ordering provider is OBR.16 (NPI^Last^First).
655 // Might not need to create a dummy encounter.
656 // Need also provider_id (probably), patient_id, date_ordered, lab_id.
657 // We have observation date/time in OBR.7.
658 // We have report date/time in OBR.22.
659 // We do not have an order date.
661 $external_order_id = empty($a[2]) ?
$a[3] : $a[2];
664 if (!$in_orderid && $external_order_id) {
665 $porow = sqlQuery("SELECT * FROM procedure_order " .
666 "WHERE lab_id = ? AND control_id = ? " .
667 "ORDER BY procedure_order_id DESC LIMIT 1",
668 array($lab_id, $external_order_id));
670 if (!empty($porow)) {
671 $in_orderid = intval($porow['procedure_order_id']);
676 // Need to identify the ordering provider and, if possible, a recent encounter.
677 $datetime_report = rhl7DateTime($a[22]);
678 $date_report = substr($datetime_report, 0, 10) . ' 00:00:00';
681 // Look for the most recent encounter within 30 days of the report date.
682 $encrow = sqlQuery("SELECT encounter FROM form_encounter WHERE " .
683 "pid = ? AND date <= ? AND DATE_ADD(date, INTERVAL 30 DAY) > ? " .
684 "ORDER BY date DESC, encounter DESC LIMIT 1",
685 array($patient_id, $date_report, $date_report));
686 if (!empty($encrow)) {
687 $encounter_id = intval($encrow['encounter']);
688 $provider_id = intval($encrow['provider_id']);
691 // Attempt ordering provider matching by name or NPI.
692 $oprow = match_provider(explode($d2, $a[16]));
693 if (!empty($oprow)) $provider_id = intval($oprow['id']);
696 // Now create the procedure order.
697 $in_orderid = sqlInsert("INSERT INTO procedure_order SET " .
698 "date_ordered = ?, " .
699 "provider_id = ?, " .
701 "date_collected = ?, " .
702 "date_transmitted = ?, " .
704 "encounter_id = ?, " .
706 array($datetime_report, $provider_id, $lab_id, rhl7DateTime($a[22]),
707 rhl7DateTime($a[7]), $patient_id, $encounter_id, $external_order_id));
708 // If an encounter was identified then link the order to it.
709 if ($encounter_id && $in_orderid) {
710 addForm($encounter_id, "Procedure Order", $in_orderid, "procedure_order", $patient_id);
714 } // end results-only
716 $porow = sqlQuery("SELECT * FROM procedure_order WHERE " .
717 "procedure_order_id = ?", array($in_orderid));
718 // The order must already exist. Currently we do not handle electronic
719 // results returned for manual orders.
720 if (empty($porow) && !($dryrun && $direction == 'R')) {
721 return rhl7LogMsg(xl('Procedure order not found') . ": $in_orderid", true);
724 if ($direction != 'R' && $porow['encounter_id'] != $in_encounter) {
725 return rhl7LogMsg(xl('Encounter ID') .
726 " '" . $porow['encounter_id'] . "' " .
727 xl('for OBR placer order number') .
729 xl('does not match the PV1 encounter number') .
734 // They did not return an encounter number to verify, so more checking
735 // might be done here to make sure the patient seems to match.
737 // Save the lab's control ID if there is one.
738 $tmp = explode($d2, $a[3]);
739 $control_id = $tmp[0];
740 if ($control_id && empty($porow['control_id'])) {
741 sqlStatement("UPDATE procedure_order SET control_id = ? WHERE " .
742 "procedure_order_id = ?", array($control_id, $in_orderid));
744 $code_seq_array = array();
746 // Find the order line item (procedure code) that matches this result.
747 // If there is more than one, then we select the one whose sequence number
748 // is next after the last sequence number encountered for this procedure
749 // code; this assumes that result OBRs are returned in the same sequence
750 // as the corresponding OBRs in the order.
751 if (!isset($code_seq_array[$in_procedure_code])) {
752 $code_seq_array[$in_procedure_code] = 0;
754 $pcquery = "SELECT pc.* FROM procedure_order_code AS pc " .
755 "WHERE pc.procedure_order_id = ? AND pc.procedure_code = ? " .
756 "ORDER BY (procedure_order_seq <= ?), procedure_order_seq LIMIT 1";
757 $pcqueryargs = array($in_orderid, $in_procedure_code, $code_seq_array[$in_procedure_code]);
758 $pcrow = sqlQuery($pcquery, $pcqueryargs);
760 // There is no matching procedure in the order, so it must have been
761 // added after the original order was sent, either as a manual request
762 // from the physician or as a "reflex" from the lab.
763 // procedure_source = '2' indicates this.
765 sqlInsert("INSERT INTO procedure_order_code SET " .
766 "procedure_order_id = ?, " .
767 "procedure_code = ?, " .
768 "procedure_name = ?, " .
769 "procedure_source = '2'",
770 array($in_orderid, $in_procedure_code, $in_procedure_name));
771 $pcrow = sqlQuery($pcquery, $pcqueryargs);
774 // Dry run, make a dummy procedure_order_code row.
776 'procedure_order_id' => $in_orderid,
777 'procedure_order_seq' => 0, // TBD?
781 $code_seq_array[$in_procedure_code] = 0 +
$pcrow['procedure_order_seq'];
783 $arep['procedure_order_id'] = $in_orderid;
784 $arep['procedure_order_seq'] = $pcrow['procedure_order_seq'];
785 $arep['date_collected'] = rhl7DateTime($a[7]);
786 $arep['date_report'] = rhl7DateTime($a[22]);
787 $arep['report_status'] = $in_report_status;
788 $arep['report_notes'] = '';
789 $arep['specimen_num'] = '';
791 // If this is a child report, add some info from the parent.
792 if (!empty($parent_ares)) {
793 $arep['report_notes'] .= xl('This is a child of result') . ' ' .
794 $parent_ares['result_code'] . ' ' . xl('with value') . ' "' .
795 $parent_ares['result'] . '".' . "\n";
797 if (!empty($parent_arep)) {
798 $arep['report_notes'] .= $parent_arep['report_notes'];
799 $arep['specimen_num'] = $parent_arep['specimen_num'];
802 // Create the main array entry for this report and its results.
804 $amain[$i] = array();
805 $amain[$i]['rep'] = $arep;
806 $amain[$i]['fid'] = $in_filler_id;
807 $amain[$i]['res'] = array();
810 else if ($a[0] == 'NTE' && $context == 'OBR') {
811 // Append this note to those for the most recent report.
812 $amain[count($amain)-1]['rep']['report_notes'] .= rhl7Text($a[3]) . "\n";
815 else if ('OBX' == $a[0] && 'ORU' == $msgtype) {
818 $ares['result_data_type'] = substr($a[2], 0, 1); // N, S, F or E
819 $ares['comments'] = $commentdelim;
821 // This is the case of results as an embedded document. We will create
822 // a normal patient document in the assigned category for lab results.
823 $tmp = explode($d2, $a[5]);
824 $fileext = strtolower($tmp[0]);
825 $filename = date("Ymd_His") . '.' . $fileext;
826 $data = rhl7DecodeData($tmp[3], $tmp[4]);
827 if ($data === FALSE) {
828 return rhl7LogMsg(xl('Invalid encapsulated data encoding type') . ': ' . $tmp[3]);
832 $rc = $d->createDocument($porow['patient_id'], $results_category_id, // TBD: Make sure not 0
833 $filename, rhl7MimeType($fileext), $data);
834 if ($rc) return rhl7LogMsg($rc);
835 $ares['document_id'] = $d->get_id();
838 else if ($a[2] == 'CWE') {
839 $ares['result'] = rhl7CWE($a[5], $d2);
841 else if ($a[2] == 'SN') {
842 $ares['result'] = trim(str_replace($d2, ' ', $a[5]));
844 else if (strlen($a[5]) > 200) {
845 // OBX-5 can be a very long string of text with "~" as line separators.
846 // The first line of comments is reserved for such things.
847 $ares['result_data_type'] = 'L';
848 $ares['result'] = '';
849 $ares['comments'] = rhl7Text($a[5]) . $commentdelim;
852 $ares['result'] = rhl7Text($a[5]);
854 $tmp = explode($d2, $a[3]);
855 $ares['result_code'] = rhl7Text($tmp[0]);
856 $ares['result_text'] = rhl7Text($tmp[1]);
857 $ares['date'] = rhl7DateTime($a[14]);
858 $ares['facility'] = rhl7Text($a[15]);
859 // Ensoftek: Units may have mutiple segments(as seen in MU2 samples), parse and take just first segment.
860 $tmp = explode($d2, $a[6]);
861 $ares['units'] = rhl7Text($tmp[0]);
862 $ares['range'] = rhl7Text($a[7]);
863 $ares['abnormal'] = rhl7Abnormal($a[8]); // values are lab dependent
864 $ares['result_status'] = rhl7ReportStatus($a[11]);
866 // Ensoftek: Performing Organization Details. Goes into "Pending Review/Patient Results--->Notes--->Facility" section.
867 $performingOrganization = getPerformingOrganizationDetails($a[23], $a[24], $a[25], $d2, $commentdelim);
868 if (!empty($performingOrganization)) {
869 $ares['facility'] .= $performingOrganization . $commentdelim;
873 // Probably need a better way to report this, if it matters.
874 if (!empty($a[19])) {
875 $ares['comments'] .= xl('Analyzed') . ' ' . rhl7DateTime($a[19]) . '.' . $commentdelim;
879 // obxkey is to allow matching this as a parent result.
880 $ares['obxkey'] = $a[3] . $d1 . $a[4];
882 // Append this result to those for the most recent report.
883 // Note the 'procedure_report_id' item is not yet present.
884 $amain[count($amain)-1]['res'][] = $ares;
887 else if ('OBX' == $a[0] && 'MDM' == $msgtype) {
890 if ($mdm_text !== '') $mdm_text .= "\r\n";
891 $mdm_text .= rhl7Text($a[5]);
894 return rhl7LogMsg(xl('Unsupported MDM OBX result type') . ': ' . $a[2]);
898 else if ('ZEF' == $a[0] && 'ORU' == $msgtype) {
899 // ZEF segment is treated like an OBX with an embedded Base64-encoded PDF.
902 $ares['result_data_type'] = 'E';
903 $ares['comments'] = $commentdelim;
906 $filename = date("Ymd_His") . '.' . $fileext;
907 $data = rhl7DecodeData('Base64', $a[2]);
908 if ($data === FALSE) return rhl7LogMsg(xl('ZEF segment internal error'));
911 $rc = $d->createDocument($porow['patient_id'], $results_category_id, // TBD: Make sure not 0
912 $filename, rhl7MimeType($fileext), $data);
913 if ($rc) return rhl7LogMsg($rc);
914 $ares['document_id'] = $d->get_id();
916 $ares['date'] = $arep['date_report'];
917 // Append this result to those for the most recent report.
918 // Note the 'procedure_report_id' item is not yet present.
919 $amain[count($amain)-1]['res'][] = $ares;
922 else if ('NTE' == $a[0] && 'OBX' == $context && 'ORU' == $msgtype) {
923 // Append this note to the most recent result item's comments.
924 $alast = count($amain) - 1;
925 $rlast = count($amain[$alast]['res']) - 1;
926 $amain[$alast]['res'][$rlast]['comments'] .= rhl7Text($a[3]) . $commentdelim;
929 // Ensoftek: Get data from SPM segment for specimen.
930 // SPM segment always occurs after the OBX segment.
931 else if ('SPM' == $a[0] && 'ORU' == $msgtype) {
932 rhl7UpdateReportWithSpecimen($amain, $a, $d2);
935 // Add code here for any other segment types that may be present.
937 // Ensoftek: Get data from SPM segment for specimen. Comes in with MU2 samples, but can be ignored.
938 else if ('TQ1' == $a[0] && 'ORU' == $msgtype) {
939 // Ignore and do nothing.
943 return rhl7LogMsg(xl('Segment name') . " '${a[0]}' " . xl('is misplaced or unknown'));
947 // Write all reports and their results to the database.
948 // This will do nothing if a dry run or MDM message type.
949 if ('ORU' == $msgtype && !$dryrun) {
950 rhl7FlushMain($amain);
953 if ('MDM' == $msgtype && !$dryrun) {
955 $rc = rhl7FlushMDM($patient_id, $mdm_docname, $mdm_datetime, $mdm_text, $mdm_category_id,
956 $oprow ?
$oprow['username'] : 0);
957 if ($rc) return rhl7LogMsg($rc);
964 * Poll all eligible labs for new results and store them in the database.
966 * @param array &$info Conveys information to and from the caller:
968 * $info["$ppid/$filename"]['delete'] = a non-empty value if file deletion is requested.
969 * $info['select'] = array of patient matching responses where key is serialized patient
970 * attributes and value is selected pid for this patient, or 0 to create the patient.
972 * $info["$ppid/$filename"]['mssgs'] = array of messages from this function.
973 * $info['match'] = array of patient matching requests where key is serialized patient
974 * attributes (ss, fname, lname, DOB) and value is TRUE (irrelevant).
976 * @return string Error text, or empty if no errors.
978 function poll_hl7_results(&$info) {
981 // echo "<!-- post: "; print_r($_POST); echo " -->\n"; // debugging
982 // echo "<!-- in: "; print_r($info); echo " -->\n"; // debugging
987 if (!isset($info['match' ])) $info['match' ] = array(); // match requests
988 if (!isset($info['select'])) $info['select'] = array(); // match request responses
990 $ppres = sqlStatement("SELECT * FROM procedure_providers ORDER BY name");
992 while ($pprow = sqlFetchArray($ppres)) {
993 $ppid = $pprow['ppid'];
994 $protocol = $pprow['protocol'];
995 $remote_host = $pprow['remote_host'];
998 if ($protocol == 'SFTP') {
1000 // Hostname may have ":port" appended to specify a nonstandard port number.
1001 if ($i = strrpos($remote_host, ':')) {
1002 $remote_port = 0 +
substr($remote_host, $i +
1);
1003 $remote_host = substr($remote_host, 0, $i);
1005 ini_set('include_path', ini_get('include_path') . PATH_SEPARATOR
. "$srcdir/phpseclib");
1006 require_once("$srcdir/phpseclib/Net/SFTP.php");
1007 // Compute the target path name.
1009 if ($pprow['results_path']) $pathname = $pprow['results_path'] . '/' . $pathname;
1010 // Connect to the server and enumerate files to process.
1011 $sftp = new Net_SFTP($remote_host, $remote_port);
1012 if (!$sftp->login($pprow['login'], $pprow['password'])) {
1013 return xl('Login to remote host') . " '$remote_host' " . xl('failed');
1015 $files = $sftp->nlist($pathname);
1016 foreach ($files as $file) {
1017 if (substr($file, 0, 1) == '.') continue;
1019 if (!isset($info["$ppid/$file"])) $info["$ppid/$file"] = array();
1020 // Ensure that archive directory exists.
1021 $prpath = $GLOBALS['OE_SITE_DIR'] . "/procedure_results";
1022 if (!file_exists($prpath)) mkdir($prpath);
1023 $prpath .= '/' . $pprow['ppid'];
1024 if (!file_exists($prpath)) mkdir($prpath);
1025 // Get file contents.
1026 $hl7 = $sftp->get("$pathname/$file");
1027 // If user requested reject and delete, do that.
1028 if (!empty($info["$ppid/$file"]['delete'])) {
1029 $fh = fopen("$prpath/$file.rejected", 'w');
1035 return xl('Cannot create file') . ' "' . "$prpath/$file.rejected" . '"';
1037 if (!$sftp->delete("$pathname/$file")) {
1038 return xl('Cannot delete (from SFTP server) file') . ' "' . "$pathname/$file" . '"';
1042 // Do a dry run of its contents and check for errors and match requests.
1043 $tmp = receive_hl7_results($hl7, $info['match'], $ppid, $pprow['direction'], true, $info['select']);
1044 $info["$ppid/$file"]['mssgs'] = $tmp['mssgs'];
1045 // $info["$ppid/$file"]['match'] = $tmp['match'];
1046 if (!empty($tmp['fatal']) ||
!empty($tmp['needmatch'])) {
1047 // There are errors or matching requests so skip this file.
1050 // Now the money shot - not a dry run.
1051 $tmp = receive_hl7_results($hl7, $info['match'], $ppid, $pprow['direction'], false, $info['select']);
1052 $info["$ppid/$file"]['mssgs'] = $tmp['mssgs'];
1053 // $info["$ppid/$file"]['match'] = $tmp['match'];
1054 if (empty($tmp['fatal']) && empty($tmp['needmatch'])) {
1055 // It worked, archive and delete the file.
1056 $fh = fopen("$prpath/$file", 'w');
1062 return xl('Cannot create file') . ' "' . "$prpath/$file" . '"';
1064 if (!$sftp->delete("$pathname/$file")) {
1065 return xl('Cannot delete (from SFTP server) file') . ' "' . "$pathname/$file" . '"';
1068 } // end of this file
1071 else if ($protocol == 'FS') {
1072 // Filesystem directory containing results files.
1073 $pathname = $pprow['results_path'];
1074 if (!($dh = opendir($pathname))) {
1075 return xl('Unable to access directory') . " '$pathname'";
1077 // Sort by filename just because.
1079 while (false !== ($file = readdir($dh))) {
1080 if (substr($file, 0, 1) == '.') continue;
1081 $files[$file] = $file;
1086 foreach ($files as $file) {
1088 if (!isset($info["$ppid/$file"])) $info["$ppid/$file"] = array();
1089 // Ensure that archive directory exists.
1090 $prpath = $GLOBALS['OE_SITE_DIR'] . "/procedure_results";
1091 if (!file_exists($prpath)) mkdir($prpath);
1092 $prpath .= '/' . $pprow['ppid'];
1093 if (!file_exists($prpath)) mkdir($prpath);
1094 // Get file contents.
1095 $hl7 = file_get_contents("$pathname/$file");
1096 // If user requested reject and delete, do that.
1097 if (!empty($info["$ppid/$file"]['delete'])) {
1098 $fh = fopen("$prpath/$file.rejected", 'w');
1104 return xl('Cannot create file') . ' "' . "$prpath/$file.rejected" . '"';
1106 if (!unlink("$pathname/$file")) {
1107 return xl('Cannot delete file') . ' "' . "$pathname/$file" . '"';
1111 // Do a dry run of its contents and check for errors and match requests.
1112 $tmp = receive_hl7_results($hl7, $info['match'], $ppid, $pprow['direction'], true, $info['select']);
1113 $info["$ppid/$file"]['mssgs'] = $tmp['mssgs'];
1114 // $info["$ppid/$file"]['match'] = $tmp['match'];
1115 if (!empty($tmp['fatal']) ||
!empty($tmp['needmatch'])) {
1116 // There are errors or matching requests so skip this file.
1119 // Now the money shot - not a dry run.
1120 $tmp = receive_hl7_results($hl7, $info['match'], $ppid, $pprow['direction'], false, $info['select']);
1121 $info["$ppid/$file"]['mssgs'] = $tmp['mssgs'];
1122 // $info["$ppid/$file"]['match'] = $tmp['match'];
1123 if (empty($tmp['fatal']) && empty($tmp['needmatch'])) {
1124 // It worked, archive and delete the file.
1125 $fh = fopen("$prpath/$file", 'w');
1131 return xl('Cannot create file') . ' "' . "$prpath/$file" . '"';
1133 if (!unlink("$pathname/$file")) {
1134 return xl('Cannot delete file') . ' "' . "$pathname/$file" . '"';
1137 } // end of this file
1138 } // end FS protocol
1140 // TBD: Insert "else if ($protocol == '???') {...}" to support other protocols.
1142 } // end procedure provider
1144 // echo "<!-- out: "; print_r($info); echo " -->\n"; // debugging
1148 // PHP end tag omitted intentionally.