From 539898e17adb716688c9969c0338fcd7145cdeca Mon Sep 17 00:00:00 2001 From: Jerry Padgett Date: Mon, 3 Feb 2025 23:42:43 -0500 Subject: [PATCH] Import CCDA Improvements and Fixes (#7935) * Import CCDA Improvements and Fixes Starting entire import templae refactor to use x-pathing. fix careplan report content display new classes for component parsing helper new classes for consultation note parsing and parsing text components for import. * style * Add Guardian Add medication such as it is! * consolidate labs by date this is temp for test * refactor buildLabArray generate doc blocks * php error fix fix deleter to delete encounters instead of soft delete on patient delete * PSR --- interface/forms/care_plan/report.php | 8 +- .../src/Controller/RCFaxClient.php | 2 +- .../Application/Model/ApplicationTable.php | 258 ++++++++------- .../Model/CarecoordinationTable.php | 312 +++++++++++++++--- .../Model/CcdaServiceDocumentRequestor.php | 38 ++- interface/patient_file/deleter.php | 9 +- src/Services/Cda/CdaComponentParseHelpers.php | 76 +++++ src/Services/Cda/CdaTemplateImportDispose.php | 138 ++++++-- src/Services/Cda/CdaTemplateParse.php | 154 ++++++++- src/Services/Cda/CdaTextParser.php | 130 ++++++++ src/Services/Cda/ProgressNoteParser.php | 172 ++++++++++ 11 files changed, 1063 insertions(+), 234 deletions(-) create mode 100644 src/Services/Cda/CdaComponentParseHelpers.php create mode 100644 src/Services/Cda/CdaTextParser.php create mode 100644 src/Services/Cda/ProgressNoteParser.php diff --git a/interface/forms/care_plan/report.php b/interface/forms/care_plan/report.php index 1ec0b032898..7eccb79d359 100644 --- a/interface/forms/care_plan/report.php +++ b/interface/forms/care_plan/report.php @@ -48,10 +48,10 @@ function care_plan_report($pid, $encounter, $cols, $id): void - - - - + + + + diff --git a/interface/modules/custom_modules/oe-module-faxsms/src/Controller/RCFaxClient.php b/interface/modules/custom_modules/oe-module-faxsms/src/Controller/RCFaxClient.php index d713f62c810..43b7c4d0aa9 100644 --- a/interface/modules/custom_modules/oe-module-faxsms/src/Controller/RCFaxClient.php +++ b/interface/modules/custom_modules/oe-module-faxsms/src/Controller/RCFaxClient.php @@ -151,7 +151,7 @@ private function initializeSDK(): void * @param string[] $acl * @return int */ - public function authenticate($acl = ['admin', 'doc']): int + public function authenticate($acl = ['admin', 'doc']): bool|int|string { if (empty($this->credentials['appKey'])) { $this->credentials = $this->getCredentials(); diff --git a/interface/modules/zend_modules/module/Application/src/Application/Model/ApplicationTable.php b/interface/modules/zend_modules/module/Application/src/Application/Model/ApplicationTable.php index dcc3388e108..7f15cf9fb43 100644 --- a/interface/modules/zend_modules/module/Application/src/Application/Model/ApplicationTable.php +++ b/interface/modules/zend_modules/module/Application/src/Application/Model/ApplicationTable.php @@ -12,6 +12,8 @@ namespace Application\Model; +use DateTime; +use Exception; use Laminas\Db\Adapter\ExceptionInterface; use Laminas\Db\TableGateway\AbstractTableGateway; use Laminas\Db\ResultSet\ResultSet; @@ -40,10 +42,10 @@ public function __construct() * Function zQuery * All DB Transactions take place * - * @param String $sql SQL Query Statment - * @param array $params SQL Parameters - * @param boolean $log Logging True / False - * @param boolean $error Error Display True / False + * @param String $sql SQL Query Statment + * @param array $params SQL Parameters + * @param boolean $log Logging True / False + * @param boolean $error Error Display True / False * @return type */ public function zQuery($sql, $params = '', $log = true, $error = true) @@ -59,9 +61,9 @@ public function zQuery($sql, $params = '', $log = true, $error = true) } try { - $statement = $this->adapter->query($sql); - $return = $statement->execute($params); - $result = true; + $statement = $this->adapter->query($sql); + $return = $statement->execute($params); + $result = true; } catch (ExceptionInterface $e) { if ($error) { $this->errorHandler($e, $sql, $params); @@ -72,14 +74,15 @@ public function zQuery($sql, $params = '', $log = true, $error = true) } } - /** - * Function auditSQLEvent - * Logging Mechanism - * - * using OpenEMR log function (auditSQLEvent) - * @see EventAuditLogger::auditSQLEvent - * Logging, if the $log is true - */ + /** + * Function auditSQLEvent + * Logging Mechanism + * + * using OpenEMR log function (auditSQLEvent) + * + * @see EventAuditLogger::auditSQLEvent + * Logging, if the $log is true + */ if ($log) { EventAuditLogger::instance()->auditSQLEvent($sql, $result, $params); } @@ -94,14 +97,14 @@ public function zQuery($sql, $params = '', $log = true, $error = true) * Same behavior of HelpfulDie fuction in OpenEMR * Path /library/sql.inc.php * - * @param type $e - * @param string $sql - * @param array $binds + * @param type $e + * @param string $sql + * @param array $binds */ public function errorHandler($e, $sql, $binds = '') { $escaper = new \Laminas\Escaper\Escaper('utf-8'); - $trace = $e->getTraceAsString(); + $trace = $e->getTraceAsString(); $nLast = strpos($trace, '[internal function]'); $trace = substr($trace, 0, ($nLast - 3)); $logMsg = ''; @@ -162,13 +165,13 @@ public function quoteValue($value) * Path openemr/src/Common/Acl/AclMain.php * Function Name zhAclCheck * - * @param int $user_id Auth user Id - * $param String $section_identifier ACL Section id + * @param int $user_id Auth user Id + * $param String $section_identifier ACL Section id * @return boolean */ public function zAclCheck($user_id, $section_identifier) { - $sql_user_acl = " SELECT + $sql_user_acl = " SELECT COUNT(allowed) AS count FROM module_acl_user_settings AS usr_settings @@ -176,7 +179,7 @@ public function zAclCheck($user_id, $section_identifier) ON usr_settings.section_id = acl_sections.`section_id` WHERE acl_sections.section_identifier = ? AND usr_settings.user_id = ? AND usr_settings.allowed = ?"; - $sql_group_acl = " SELECT + $sql_group_acl = " SELECT COUNT(allowed) AS count FROM module_acl_group_settings AS group_settings @@ -197,7 +200,7 @@ public function zAclCheck($user_id, $section_identifier) WHERE garo.section_value = ? AND usr. id = ?"; - $res_groups = $this->zQuery($sql_user_group, array('users',$user_id)); + $res_groups = $this->zQuery($sql_user_group, array('users', $user_id)); $groups = array(); foreach ($res_groups as $row) { array_push($groups, $row['group_id']); @@ -205,29 +208,29 @@ public function zAclCheck($user_id, $section_identifier) $groups_str = implode(",", $groups); - $count_user_denied = 0; - $count_user_allowed = 0; - $count_group_denied = 0; - $count_group_allowed = 0; + $count_user_denied = 0; + $count_user_allowed = 0; + $count_group_denied = 0; + $count_group_allowed = 0; - $res_user_denied = $this->zQuery($sql_user_acl, array($section_identifier,$user_id,0)); + $res_user_denied = $this->zQuery($sql_user_acl, array($section_identifier, $user_id, 0)); foreach ($res_user_denied as $row) { - $count_user_denied = $row['count']; + $count_user_denied = $row['count']; } - $res_user_allowed = $this->zQuery($sql_user_acl, array($section_identifier,$user_id,1)); + $res_user_allowed = $this->zQuery($sql_user_acl, array($section_identifier, $user_id, 1)); foreach ($res_user_allowed as $row) { - $count_user_allowed = $row['count']; + $count_user_allowed = $row['count']; } - $res_group_denied = $this->zQuery($sql_group_acl, array($section_identifier,$groups_str,0)); + $res_group_denied = $this->zQuery($sql_group_acl, array($section_identifier, $groups_str, 0)); foreach ($res_group_denied as $row) { - $count_group_denied = $row['count']; + $count_group_denied = $row['count']; } - $res_group_allowed = $this->zQuery($sql_group_acl, array($section_identifier,$groups_str,1)); + $res_group_allowed = $this->zQuery($sql_group_acl, array($section_identifier, $groups_str, 1)); foreach ($res_group_allowed as $row) { - $count_group_allowed = $row['count']; + $count_group_allowed = $row['count']; } if ($count_user_denied > 0) { @@ -248,37 +251,37 @@ public function zAclCheck($user_id, $section_identifier) */ public function listAutoSuggest($post, $limit) { - $pages = 0; - $limitEnd = \Application\Plugin\CommonPlugin::escapeLimit($limit); + $pages = 0; + $limitEnd = \Application\Plugin\CommonPlugin::escapeLimit($limit); if (isset($GLOBALS['set_autosuggest_options'])) { if ($GLOBALS['set_autosuggest_options'] == 1) { - $leading = '%'; + $leading = '%'; } else { - $leading = $post->leading; + $leading = $post->leading; } if ($GLOBALS['set_autosuggest_options'] == 2) { - $trailing = '%'; + $trailing = '%'; } else { - $trailing = $post->trailing; + $trailing = $post->trailing; } if ($GLOBALS['set_autosuggest_options'] == 3) { - $leading = '%'; - $trailing = '%'; + $leading = '%'; + $trailing = '%'; } } else { - $leading = $post->leading; - $trailing = $post->trailing; + $leading = $post->leading; + $trailing = $post->trailing; } - $queryString = $post->queryString; + $queryString = $post->queryString; - $page = $post->page; - $searchType = $post->searchType; - $searchEleNo = $post->searchEleNo; + $page = $post->page; + $searchType = $post->searchType; + $searchEleNo = $post->searchEleNo; if ($page == '') { $limitStart = 0; @@ -297,24 +300,24 @@ public function listAutoSuggest($post, $limit) OR DATE_FORMAT(DOB,'%Y-%m-%d') LIKE ? ORDER BY fname "; $result = $this->zQuery($sql, array( - $keyword, - $keyword, - $keyword, - $keyword, - $keyword, - $keyword - )); - $rowCount = $result->count(); - $sql .= "LIMIT $limitStart, $limitEnd"; - $result = $this->zQuery($sql, array( - $keyword, - $keyword, - $keyword, - $keyword, - $keyword, - $keyword, - - )); + $keyword, + $keyword, + $keyword, + $keyword, + $keyword, + $keyword + )); + $rowCount = $result->count(); + $sql .= "LIMIT $limitStart, $limitEnd"; + $result = $this->zQuery($sql, array( + $keyword, + $keyword, + $keyword, + $keyword, + $keyword, + $keyword, + + )); } elseif (strtolower($searchType) == 'emrdirect') { $sql = "SELECT fname, mname, lname,email_direct AS 'email',id FROM users WHERE (CONCAT(fname, ' ', lname) LIKE ? @@ -324,17 +327,17 @@ public function listAutoSuggest($post, $limit) AND active = 1 ORDER BY fname "; $result = $this->zQuery($sql, array( - $keyword, - $keyword, - $keyword, - )); - $rowCount = $result->count(); - $sql .= "LIMIT $limitStart, $limitEnd"; - $result = $this->zQuery($sql, array( - $keyword, - $keyword, - $keyword, - )); + $keyword, + $keyword, + $keyword, + )); + $rowCount = $result->count(); + $sql .= "LIMIT $limitStart, $limitEnd"; + $result = $this->zQuery($sql, array( + $keyword, + $keyword, + $keyword, + )); } $arr = array(); @@ -349,25 +352,27 @@ public function listAutoSuggest($post, $limit) return $arr; } - /* - * Retrive the data format from GLOBALS - * - * @param Date format set in GLOBALS - * @return Date format in PHP - **/ + /** + * Converts a given format setting (or string) to a PHP date format. + * + * @param mixed $format 0, 1, 2, or a custom format string. + * @return string PHP date format string. + */ public static function dateFormat($format = null) { - if ($format == "0") { - $date_format = 'yyyy-mm-dd'; - } elseif ($format == 1) { - $date_format = 'mm/dd/yyyy'; - } elseif ($format == 2) { - $date_format = 'dd/mm/yyyy'; - } else { - $date_format = $format; + $map = [ + '0' => 'Y-m-d', // e.g. "1920-01-01" + 1 => 'm/d/Y', // e.g. "01/01/1920" + 2 => 'd/m/Y', // e.g. "01/01/1920" + 'yyyy-mm-dd' => 'Y-m-d', + 'mm/dd/yyyy' => 'm/d/Y', + 'dd/mm/yyyy' => 'd/m/Y', + ]; + if (isset($map[$format])) { + return $map[$format]; } - return $date_format; + return $format; } /* @@ -390,46 +395,51 @@ public static function datePickerFormat($format = null) return $date_format; } + /** - * fixDate - Date Conversion Between Different Formats - * @param String $input_date Date to be converted - * @param String $date_format Target Date Format - */ + * Converts an input date from one format to another. + * + * @param string $input_date The date to convert. + * @param mixed $output_format The desired output format (as defined by dateFormat). + * @param mixed $input_format The format of the input date (as defined by dateFormat). + * If null, the method will attempt to detect the format. + * @return string|false The formatted date or false if conversion fails. + */ public static function fixDate($input_date, $output_format = null, $input_format = null) { if (!$input_date) { - return; + return false; } - $input_date = preg_replace('/T|Z/', ' ', $input_date); - - $temp = explode(' ', $input_date); //split using space and consider the first portion, in case of date with time - $input_date = $temp[0]; - - $output_format = \Application\Model\ApplicationTable::dateFormat($output_format); - $input_format = \Application\Model\ApplicationTable::dateFormat($input_format); - - preg_match("/[^ymd]/", $output_format, $date_seperator_output); - $seperator_output = $date_seperator_output[0]; - $output_date_arr = explode($seperator_output, $output_format); - - preg_match("/[^ymd]/", $input_format, $date_seperator_input); - $seperator_input = $date_seperator_input[0]; - $input_date_array = explode($seperator_input, $input_format); - - preg_match("/[^1234567890]/", $input_date, $date_seperator_input); - $seperator_input = $date_seperator_input[0]; - $input_date_arr = explode($seperator_input, $input_date); + $input_date = preg_replace('/[TZ]/', ' ', $input_date); + $outputFormat = self::dateFormat($output_format); - foreach ($output_date_arr as $key => $format) { - $index = array_search($format, $input_date_array); - $output_date_arr[$key] = $input_date_arr[$index]; + if ($input_format) { + $inputFormat = self::dateFormat($input_format); + } else { + if (preg_match('/^\d{8}$/', $input_date)) { + $inputFormat = 'Ymd'; + } elseif (preg_match('/^\d{14}$/', $input_date)) { + $inputFormat = 'YmdHis'; + } else { + $inputFormat = null; + } + } + if ($inputFormat) { + $dateObj = DateTime::createFromFormat($inputFormat, $input_date); + } else { + try { + $dateObj = new DateTime($input_date); + } catch (Exception $e) { + return false; + } } - $output_date = implode($seperator_output, $output_date_arr); + if (!$dateObj) { + return false; + } - $output_date = (!empty($temp[1])) ? $output_date . " " . $temp[1] : $output_date; //append the time, if exists, with the new formatted date - return $output_date; + return $dateObj->format($outputFormat); } /* diff --git a/interface/modules/zend_modules/module/Carecoordination/src/Carecoordination/Model/CarecoordinationTable.php b/interface/modules/zend_modules/module/Carecoordination/src/Carecoordination/Model/CarecoordinationTable.php index 916aa7c2f6f..dd2fa5002cc 100644 --- a/interface/modules/zend_modules/module/Carecoordination/src/Carecoordination/Model/CarecoordinationTable.php +++ b/interface/modules/zend_modules/module/Carecoordination/src/Carecoordination/Model/CarecoordinationTable.php @@ -18,6 +18,8 @@ use Application\Plugin\CommonPlugin; use Documents\Model\DocumentsTable; use Documents\Plugin\Documents; +use DOMDocument; +use DOMXPath; use Exception; use Laminas\Config\Reader\ReaderInterface; use Laminas\Config\Reader\Xml; @@ -25,6 +27,8 @@ use OpenEMR\Common\Command\Trait\CommandLineDebugStylerTrait; use OpenEMR\Services\Cda\CdaTemplateImportDispose; use OpenEMR\Services\Cda\CdaTemplateParse; +use OpenEMR\Services\Cda\CdaComponentParseHelpers; +use OpenEMR\Services\Cda\CdaTextParser; use OpenEMR\Services\Cda\CdaValidateDocuments; use OpenEMR\Services\Cda\XmlExtended; use OpenEMR\Services\CodeTypesService; @@ -45,6 +49,7 @@ class CarecoordinationTable extends AbstractTableGateway private $parseTemplates; private $codeService; private $importService; + public $conditionedXmlContent; public function __construct() { @@ -55,6 +60,9 @@ public function __construct() $this->validationIsDisabled = $GLOBALS['ccda_validation_disable'] ?? false; } + /** + * @return CdaTemplateImportDispose + */ public function getImportService(): CdaTemplateImportDispose { return $this->importService; @@ -66,6 +74,10 @@ public function getImportService(): CdaTemplateImportDispose * @param $title String Category Name * @return $records Array Category ID */ + /** + * @param $title + * @return array + */ public function fetch_cat_id($title): array { $appTable = new ApplicationTable(); @@ -90,6 +102,10 @@ public function fetch_cat_id($title): array * * @return records Array List of documents uploaded by the user during a particular time */ + /** + * @param $data + * @return array + */ public function fetch_uploaded_documents($data): array { $query = "SELECT * @@ -112,6 +128,10 @@ public function fetch_uploaded_documents($data): array * @param cat_title Text Category Name * @return records Array List of CCDA imported to the system, pending approval */ + /** + * @param $data + * @return array + */ public function document_fetch($data): array { $direction = $_REQUEST['sort_direction'] ?? 'DESC'; @@ -173,6 +193,11 @@ public function document_fetch($data): array * * @param $document Path to xml document */ + /** + * @param $document + * @return void + * @throws Exception + */ public function importNewPatient($document): void { if (!file_exists($document)) { @@ -192,18 +217,16 @@ public function importNewPatient($document): void */ public function importCore($xml_content, $doc_id = null): void { - $xml_content_new = preg_replace('#
#', '', $xml_content); - $xml_content_new = preg_replace('#
#', '', $xml_content_new); - $xml_content_new = (string)str_replace(array("\n ", "\n", "\r"), '', $xml_content_new); - // Note the behavior of this relies on PHP's XMLReader // @see https://docs.zendframework.com/zend-config/reader/ // @see https://php.net/xmlreader // 10/27/2022 sjp Extended base reader Laminas XML class using provided interface class // Needed to add LIBXML_COMPACT | LIBXML_PARSEHUGE flags because large text node(>10MB) will fail. try { - $xmltoarray = new XmlExtended(); - $xml = $xmltoarray->fromString($xml_content_new); + $this->conditionedXmlContent = $this->cleanCcdaXmlContent($xml_content, true); + $this->parseTemplates->conditionedXmlContent = $this->conditionedXmlContent; + $xml_to_array = new XmlExtended(); + $xml = $xml_to_array->fromString($this->conditionedXmlContent); } catch (Exception $e) { die($e->getMessage()); } @@ -212,10 +235,11 @@ public function importCore($xml_content, $doc_id = null): void $qrda_log['message'] = $validation_log = null; // test if a QRDA QDM CAT I document type from header OIDs $template_oid = $xml['templateId'][2]['root'] ?? null; + // is it QRDA document? if ($template_oid === '2.16.840.1.113883.10.20.24.1.2') { $this->is_qrda_import = 1; if (!empty($doc_id) && !$this->validationIsDisabled) { - $validation_log = $this->validateDocument->validateDocument($xml_content_new, 'qrda1'); + $validation_log = $this->validateDocument->validateDocument($this->conditionedXmlContent, 'qrda1'); } if (count($components[2]["section"]["entry"] ?? []) < 2) { $name = $xml["recordTarget"]["patientRole"]["patient"]["name"]["given"] . ' ' . @@ -227,9 +251,11 @@ public function importCore($xml_content, $doc_id = null): void // Offset to Patient Data section $this->documentData = $this->parseTemplates->parseQRDAPatientDataSection($components[2]); } else { + // nope, it's a CCDA document! if (!empty($doc_id) && !$this->validationIsDisabled) { - $validation_log = $this->validateDocument->validateDocument($xml_content_new, 'ccda'); + $validation_log = $this->validateDocument->validateDocument($this->conditionedXmlContent, 'ccda'); } + // do we have a structured import? if ($template_oid === '2.16.840.1.113883.10.20.22.1.10') { $this->is_unstructured_import = true; $this->documentData = $this->parseTemplates->parseUnstructuredComponents($xml); @@ -365,6 +391,18 @@ public function importCore($xml_content, $doc_id = null): void $patient_language = sqlQuery("SELECT `option_id` FROM `list_options` WHERE `list_id` = 'language' And `notes` = ?", [$patient_language])['option_id']; $this->documentData['field_name_value_array']['patient_data'][1]['language'] = $patient_language ?: ''; + // Guardian Details + $parser = new CdaComponentParseHelpers($this->conditionedXmlContent); + $guardian = $parser->parseGuardianParticipant(); + $this->documentData['field_name_value_array']['patient_data'][1]['guardiansname'] = $guardian['name'][0]['given'] . ' ' . ($guardian['name'][1]['given'] ?? '') . ' ' . $guardian['name']['family'] ?? null; + $this->documentData['field_name_value_array']['patient_data'][1]['guardianaddress'] = $guardian['address']['street'] ?? null; + $this->documentData['field_name_value_array']['patient_data'][1]['guardiancity'] = $guardian['address']['city'] ?? null; + $this->documentData['field_name_value_array']['patient_data'][1]['guardianstate'] = $guardian['address']['state'] ?? null; + $this->documentData['field_name_value_array']['patient_data'][1]['guardianpostalcode'] = $guardian['address']['postalCode'] ?? null; + $this->documentData['field_name_value_array']['patient_data'][1]['guardiancountry'] = $guardian['address']['country'] ?? null; + $this->documentData['field_name_value_array']['patient_data'][1]['guardianphone'] = $guardian['contact']['HP'] ?? null; + $this->documentData['field_name_value_array']['patient_data'][1]['guardianworkphone'] = $guardian['contact']['WP'] ?? $guardian['contact']['MC'] ?? null; + //Author details $this->documentData['field_name_value_array']['author'][1]['extension'] = $xml['author']['assignedAuthor']['id']['extension'] ?? null; $this->documentData['field_name_value_array']['author'][1]['address'] = $xml['author']['assignedAuthor']['addr']['streetAddressLine'] ?? null; @@ -440,6 +478,11 @@ public function importCore($xml_content, $doc_id = null): void * @param $xml_content The xml document */ + /** + * @param $audit_master_id + * @param $document_id + * @return void + */ public function insert_patient($audit_master_id, $document_id) { require_once(__DIR__ . "/../../../../../../../../library/patient.inc.php"); @@ -448,7 +491,7 @@ public function insert_patient($audit_master_id, $document_id) $b = 1; $c = 1; $d = 1; - $e = 1; + $e = 1; // care plan $f = 1; $g = 1; $h = 1; @@ -458,6 +501,7 @@ public function insert_patient($audit_master_id, $document_id) $p = 1; // payer QRDA $q = 1; $y = 1; + $cn = 1; //clinical note $arr_procedure_res = array(); $arr_encounter = array(); @@ -471,6 +515,7 @@ public function insert_patient($audit_master_id, $document_id) $arr_functional_cognitive_status = array(); $arr_referral = array(); $arr_observation_preformed = array(); + $arr_clinical_note = array(); $appTable = new ApplicationTable(); @@ -509,7 +554,7 @@ public function insert_patient($audit_master_id, $document_id) array($audit_master_id, $row['table_name'], $row['entry_identification']) ); } else { - // collect directly from $this->documentData (ie. no audit table middleman) + // collect directly from $this->documentData (i.e. no audit table middleman) $resfield = []; foreach ($this->documentData['field_name_value_array'][$row['table_name']][$row['entry_identification']] as $itemKey => $item) { if (is_array($item)) { @@ -574,6 +619,8 @@ public function insert_patient($audit_master_id, $document_id) $newdata['payer'][$rowfield['field_name']] = $rowfield['field_value']; } elseif ($table == 'import_file') { $newdata['import_file'][$rowfield['field_name']] = $rowfield['field_value']; + } elseif ($table == 'clinical_notes') { + $newdata['clinical_notes'][$rowfield['field_name']] = $rowfield['field_value']; } } if ($table == 'patient_data') { @@ -663,7 +710,7 @@ public function insert_patient($audit_master_id, $document_id) $arr_med_pblm['lists1'][$d]['observation_code'] = $newdata['lists1']['observation']; $arr_med_pblm['lists1'][$d]['subtype'] = $newdata['lists1']['subtype']; $d++; - } elseif ($table == 'lists2' && !empty($newdata['lists2']['list_code'])) { + } elseif ($table == 'lists2' && !empty($newdata['lists2']['list_code_text'])) { $arr_allergies['lists2'][$c]['extension'] = $newdata['lists2']['extension']; $arr_allergies['lists2'][$c]['begdate'] = $newdata['lists2']['begdate']; $arr_allergies['lists2'][$c]['enddate'] = $newdata['lists2']['enddate']; @@ -705,6 +752,7 @@ public function insert_patient($audit_master_id, $document_id) $arr_encounter['encounter'][$k]['encounter_diagnosis_code'] = $newdata['encounter']['encounter_diagnosis_code']; $arr_encounter['encounter'][$k]['encounter_diagnosis_issue'] = $newdata['encounter']['encounter_diagnosis_issue']; $arr_encounter['encounter'][$k]['encounter_discharge_code'] = $newdata['encounter']['encounter_discharge_code']; + $arr_encounter['encounter'][$k]['reason'] = $newdata['encounter']['reason']; $k++; } elseif ($table == 'vital_sign') { $arr_vitals['vitals'][$q]['extension'] = $newdata['vital_sign']['extension']; @@ -863,6 +911,13 @@ public function insert_patient($audit_master_id, $document_id) $arr_care_plan['care_plan'][$e]['reason_date_high'] = $newdata['care_plan']['reason_date_high'] ?? null; $arr_care_plan['care_plan'][$e]['reason_status'] = $newdata['care_plan']['reason_status'] ?? null; $e++; + } elseif ($table == 'clinical_notes') { + $arr_clinical_note['clinical_notes'][$cn]['date'] = $newdata['clinical_notes']['date'] ?? null; + $arr_clinical_note['clinical_notes'][$cn]['code'] = $newdata['clinical_notes']['code']; + $arr_clinical_note['clinical_notes'][$cn]['text'] = $newdata['clinical_notes']['code_text']; + $arr_clinical_note['clinical_notes'][$cn]['description'] = $newdata['clinical_notes']['description']; + $arr_clinical_note['clinical_notes'][$cn]['plan_type'] = $newdata['clinical_notes']['plan_type']; + $cn++; } elseif ($table == 'functional_cognitive_status') { $arr_functional_cognitive_status['functional_cognitive_status'][$f]['cognitive'] = $newdata['functional_cognitive_status']['cognitive']; $arr_functional_cognitive_status['functional_cognitive_status'][$f]['extension'] = $newdata['functional_cognitive_status']['extension']; @@ -920,11 +975,12 @@ public function insert_patient($audit_master_id, $document_id) $this->importService->InsertAllergies(($arr_allergies['lists2'] ?? null), $pid, $this, 0); $this->importService->InsertMedicalProblem(($arr_med_pblm['lists1'] ?? null), $pid, $this, 0); $this->importService->InsertEncounter(($arr_encounter['encounter'] ?? null), $pid, $this, 0); + $this->importService->InsertCarePlan(($arr_care_plan['care_plan'] ?? null), $pid, $this, 0); + $this->importService->InsertClinicalNote(($arr_clinical_note['clinical_notes'] ?? null), $pid, $this, 0); $this->importService->InsertVitals(($arr_vitals['vitals'] ?? null), $pid, $this, 0); $lab_results = $this->buildLabArray($arr_procedure_res['procedure_result'] ?? null); $this->importService->InsertProcedures(($arr_procedures['procedure'] ?? null), $pid, $this, 0); $this->importService->InsertLabResults($lab_results, $pid, $this); - $this->importService->InsertCarePlan(($arr_care_plan['care_plan'] ?? null), $pid, $this, 0); $this->importService->InsertFunctionalCognitiveStatus(($arr_functional_cognitive_status['functional_cognitive_status'] ?? null), $pid, $this, 0); $this->importService->InsertReferrals(($arr_referral['referral'] ?? null), $pid, 0); $this->importService->InsertObservationPerformed(($arr_observation_preformed['observation_preformed'] ?? null), $pid, $this, 0); @@ -945,6 +1001,11 @@ public function insert_patient($audit_master_id, $document_id) } } + /** + * @param $unformatted_date + * @param $ymd + * @return string + */ public function formatDate($unformatted_date, $ymd = 1) { $day = substr($unformatted_date, 6, 2); @@ -966,6 +1027,12 @@ public function formatDate($unformatted_date, $ymd = 1) * @return $content String File content */ + /** + * @param $list_id + * @param $title + * @param $codes + * @return mixed|null + */ public function getOptionId($list_id, $title, $codes = null) { $appTable = new ApplicationTable(); @@ -989,6 +1056,12 @@ public function getOptionId($list_id, $title, $codes = null) return ($res_cur['option_id'] ?? null); } + /** + * @param string|null $option_id + * @param $list_id + * @param $codes + * @return mixed|null + */ public function getListTitle(?string $option_id, $list_id, $codes = '') { $appTable = new ApplicationTable(); @@ -1012,49 +1085,50 @@ public function getListTitle(?string $option_id, $list_id, $codes = '') return ($res_cur['title'] ?? null); } + /** + * @param $lab_array + * @return array + */ public function buildLabArray($lab_array) { // nothing to build if we are empty here. if (empty($lab_array)) { return []; } - - $lab_results = array(); - $j = 0; - foreach ($lab_array as $key => $value) { - // @todo fix below conditional to work for CCD. - if (!empty($lab_results[$value['extension']]['result']) && is_countable($lab_results[$value['extension']]['result'])) { - $j = count($lab_results[$value['extension']]['result']) + 1; - $lab_results[$value['extension']]['proc_text'] = $value['proc_text']; - $lab_results[$value['extension']]['date'] = $value['date']; - $lab_results[$value['extension']]['proc_code'] = $value['proc_code']; - $lab_results[$value['extension']]['extension'] = $value['extension']; - $lab_results[$value['extension']]['status'] = $value['status']; - $lab_results[$value['extension']]['result'][$j]['result_date'] = $value['results_date']; - $lab_results[$value['extension']]['result'][$j]['result_text'] = $value['results_text']; - $lab_results[$value['extension']]['result'][$j]['result_value'] = $value['results_value']; - $lab_results[$value['extension']]['result'][$j]['result_range'] = $value['results_range']; - $lab_results[$value['extension']]['result'][$j]['result_code'] = $value['results_code']; - $lab_results[$value['extension']]['result'][$j]['result_unit'] = $value['results_unit']; - } elseif (!empty($value['extension'])) { - $j = 0; - $lab_results[$value['extension']]['proc_text'] = $value['proc_text']; - $lab_results[$value['extension']]['date'] = $value['date']; - $lab_results[$value['extension']]['proc_code'] = $value['proc_code']; - $lab_results[$value['extension']]['extension'] = $value['extension']; - $lab_results[$value['extension']]['status'] = $value['status']; - $lab_results[$value['extension']]['result'][$j]['result_date'] = $value['results_date']; - $lab_results[$value['extension']]['result'][$j]['result_text'] = $value['results_text']; - $lab_results[$value['extension']]['result'][$j]['result_value'] = $value['results_value']; - $lab_results[$value['extension']]['result'][$j]['result_range'] = $value['results_range']; - $lab_results[$value['extension']]['result'][$j]['result_code'] = $value['results_code']; - $lab_results[$value['extension']]['result'][$j]['result_unit'] = $value['results_unit']; + $groupResults = []; + foreach ($lab_array as $result) { + $formattedDate = date('Y-m-d H:i:s', strtotime($result['date'])); + if (!isset($groupResults[$formattedDate])) { + // Initialize a new group for this date + $groupResults[$formattedDate] = [ + 'date' => $formattedDate, + 'proc_text' => $result['proc_text'], + 'proc_code' => $result['proc_code'], + 'extension' => $result['extension'], + 'status' => $result['status'], + 'results' => [] + ]; } + $groupResults[$formattedDate]['results'][] = [ + 'result_date' => $result['results_date'] ?? '', + 'result_text' => $result['results_text'] ?? '', + 'result_value' => $result['results_value'] ?? '', + 'result_range' => $result['results_range'] ?? '', + 'result_code' => $result['results_code'] ?? '', + 'result_unit' => $result['results_unit'] ?? '', + ]; } - - return $lab_results; + // sequential + return array_values($groupResults); } + // hmm, can't find where this is used. + + /** + * @param $document_id + * @return void + * @throws Exception + */ public function import($document_id) { $this->resetData(); @@ -1066,11 +1140,22 @@ public function import($document_id) $this->update_document_table($document_id, $audit_master_id, $audit_master_approval_status, $documentationOf); } + /** + * @param $document_id + * @return string + */ public static function getDocument($document_id): string { return Documents::getDocument($document_id); } + /** + * @param $document_id + * @param $audit_master_id + * @param $audit_master_approval_status + * @param $documentationOf + * @return void + */ public function update_document_table($document_id, $audit_master_id, $audit_master_approval_status, $documentationOf): void { $appTable = new ApplicationTable(); @@ -1087,12 +1172,19 @@ public function update_document_table($document_id, $audit_master_id, $audit_mas $document_id)); } + /** + * @return array + */ public function getCategory() { $doc_obj = new DocumentsTable(); return $doc_obj->getCategory(); } + /** + * @param $pid + * @return mixed + */ public function getIssues($pid) { // @todo Beware getIssues() doesn't exist in DocumentTable()! Method not used @@ -1101,12 +1193,19 @@ public function getIssues($pid) return $issues; } + /** + * @return string + */ public function getCategoryIDs(): string { $doc_obj = new DocumentsTable(); return implode("|", $doc_obj->getCategoryIDs(array('CCD', 'CCR', 'CCDA'))); } + /** + * @param $data + * @return array + */ public function getDemographics($data): array { $appTable = new ApplicationTable(); @@ -1127,6 +1226,10 @@ public function getDemographics($data): array return $records; } + /** + * @param $data + * @return array + */ public function getDemographicsOld($data) { $appTable = new ApplicationTable(); @@ -1142,6 +1245,10 @@ public function getDemographicsOld($data) return $records; } + /** + * @param $data + * @return array + */ public function getProblems($data): array { $appTable = new ApplicationTable(); @@ -1157,6 +1264,10 @@ public function getProblems($data): array return $records; } + /** + * @param $data + * @return array + */ public function getAllergies($data): array { $appTable = new ApplicationTable(); @@ -1172,6 +1283,10 @@ public function getAllergies($data): array return $records; } + /** + * @param $data + * @return array + */ public function getMedications($data): array { $appTable = new ApplicationTable(); @@ -1187,6 +1302,10 @@ public function getMedications($data): array return $records; } + /** + * @param $data + * @return array + */ public function getImmunizations($data): array { $appTable = new ApplicationTable(); @@ -1202,6 +1321,10 @@ public function getImmunizations($data): array return $records; } + /** + * @param $data + * @return array + */ public function getLabResults($data): array { $appTable = new ApplicationTable(); @@ -1230,6 +1353,10 @@ public function getLabResults($data): array return $records; } + /** + * @param $data + * @return array + */ public function getVitals($data): array { $appTable = new ApplicationTable(); @@ -1245,6 +1372,10 @@ public function getVitals($data): array return $records; } + /** + * @param $data + * @return array + */ public function getSocialHistory($data): array { $appTable = new ApplicationTable(); @@ -1261,6 +1392,10 @@ public function getSocialHistory($data): array return $records; } + /** + * @param $data + * @return array + */ public function getEncounterData($data): array { $appTable = new ApplicationTable(); @@ -1278,6 +1413,10 @@ public function getEncounterData($data): array return $records; } + /** + * @param $data + * @return array + */ public function getProcedure($data): array { $appTable = new ApplicationTable(); @@ -1293,6 +1432,10 @@ public function getProcedure($data): array return $records; } + /** + * @param $data + * @return array + */ public function getCarePlan($data): array { $appTable = new ApplicationTable(); @@ -1308,6 +1451,10 @@ public function getCarePlan($data): array return $records; } + /** + * @param $data + * @return array + */ public function getFunctionalCognitiveStatus($data): array { $appTable = new ApplicationTable(); @@ -1323,6 +1470,11 @@ public function getFunctionalCognitiveStatus($data): array return $records; } + /** + * @param $am_id + * @param $table_name + * @return array + */ public function createAuditArray($am_id, $table_name): array { $appTable = new ApplicationTable(); @@ -1363,6 +1515,10 @@ public function createAuditArray($am_id, $table_name): array return $records; } + /** + * @param $data + * @return void + */ public function insertApprovedData($data) { $appTable = new ApplicationTable(); @@ -1978,6 +2134,11 @@ public function deleteImportAuditData($data) $appTable->zQuery("DELETE FROM documents WHERE audit_master_id=?", array($data['audit_master_id'])); } + /** + * @param $option_id + * @param $list_id + * @return mixed + */ public function getCodes($option_id, $list_id) { $appTable = new ApplicationTable(); @@ -1998,6 +2159,10 @@ public function getCodes($option_id, $list_id) * @param list_id string * @return records Array list of list details */ + /** + * @param $list + * @return array + */ public function getList($list) { $appTable = new ApplicationTable(); @@ -2018,6 +2183,10 @@ public function getList($list) * @return records Array list of Referral values */ + /** + * @param $data + * @return array + */ public function getReferralReason($data) { $appTable = new ApplicationTable(); @@ -2038,6 +2207,10 @@ public function getReferralReason($data) * * @param audit_master_id Integer ID from audi_master table */ + /** + * @param $audit_master_id + * @return mixed + */ public function getdocumentationOf($audit_master_id) { $appTable = new ApplicationTable(); @@ -2056,6 +2229,10 @@ public function getdocumentationOf($audit_master_id) * @param $type * @return Array $components */ + /** + * @param $type + * @return string[] + */ public function getCCDAComponents($type) { $components = array('schematron' => 'Errors'); @@ -2070,6 +2247,10 @@ public function getCCDAComponents($type) return $components; } + /** + * @param $m + * @return string|void + */ public function getMonthString($m) { $m = trim($m); @@ -2100,6 +2281,11 @@ public function getMonthString($m) } } + /** + * @param $option_id + * @param $list_id + * @return mixed|string + */ public function getListCodes($option_id, $list_id) { $appTable = new ApplicationTable(); @@ -2124,6 +2310,42 @@ private function resetData() $this->is_unstructured_import = false; $this->parseTemplates = new CdaTemplateParse(); } + + /** + * Cleans a CCDA XML document for use with Laminas XML or DOMDocument. + * Optionally removes or replaces
tags. + * + * @param string $xmlContent The raw CCDA XML string. + * @param bool $removeBr Whether to remove
tags. Defaults to false. + * @return string Cleaned XML content. + * @throws Exception If the input XML is invalid or cannot be parsed. + */ + function cleanCcdaXmlContent(string $xmlContent, bool $removeBr = false): string + { + // Handle
tags if required + if ($removeBr) { + $xmlContent = preg_replace('//', '', $xmlContent); // Remove
+ } else { + $xmlContent = preg_replace('//', "\n", $xmlContent); // Replace
with newline + } + $xmlContent = preg_replace('/\xC2\xA0/', '', $xmlContent); + $xmlContent = str_replace('Â', '', $xmlContent); + + // Load the raw XML into DOMDocument for further cleaning + $dom = new DOMDocument(); + $dom->preserveWhiteSpace = false; + $dom->formatOutput = false; + libxml_use_internal_errors(true); + if (!$dom->loadXML($xmlContent, LIBXML_NOERROR | LIBXML_NOWARNING)) { + $errors = libxml_get_errors(); + libxml_clear_errors(); + throw new Exception("Invalid XML provided: " . implode(", ", array_map(fn($e) => $e->message, $errors))); + } + // Normalize and ensure UTF-8 encoding + $dom->encoding = 'UTF-8'; + + return $dom->saveXML(); + } } // Below was removed as couldn't find it used anywhere! Will keep for a minute or two... // Maybe used to create methods in CdaTemplateParse class diff --git a/interface/modules/zend_modules/module/Carecoordination/src/Carecoordination/Model/CcdaServiceDocumentRequestor.php b/interface/modules/zend_modules/module/Carecoordination/src/Carecoordination/Model/CcdaServiceDocumentRequestor.php index 33defd649a2..f089b36bd8e 100644 --- a/interface/modules/zend_modules/module/Carecoordination/src/Carecoordination/Model/CcdaServiceDocumentRequestor.php +++ b/interface/modules/zend_modules/module/Carecoordination/src/Carecoordination/Model/CcdaServiceDocumentRequestor.php @@ -18,6 +18,9 @@ class CcdaServiceDocumentRequestor { + /** + * @throws CcdaServiceConnectionException + */ public function socket_get($data) { $output = ""; @@ -38,15 +41,21 @@ public function socket_get($data) if (IS_WINDOWS) { // node server is quite with errors(hidden process) so we'll do redirect of tty // to generally Windows/Temp. - $redirect_errors = " > " . - $system->escapeshellcmd($GLOBALS['temporary_files_dir'] . "/ccdaserver.log") . " 2>&1"; - $cmd = $system->escapeshellcmd("node " . $path . "/serveccda.js") . $redirect_errors; - $pipeHandle = popen("start /B " . $cmd, "r"); + $nodePath = 'node'; + $scriptPath = ($path . '\\serveccda.js'); + $logPath = ($GLOBALS['temporary_files_dir'] . "\\ccdaserver.log"); // redirect logs here if desired + $cmd = sprintf( + 'start /B "%s" "%s" > "%s" 2>&1', + $nodePath, + $scriptPath, + $logPath + ); + $pipeHandle = popen($cmd, 'r'); if ($pipeHandle === false) { - throw new CcdaServiceConnectionException("Failed to start local ccdaservice"); - } - if (pclose($pipeHandle) === -1) { - error_log("Failed to close pipehandle for ccdaservice"); + error_log("Failed to start Node process via popen()"); + } else { + // close the pipe + pclose($pipeHandle); } } else { $command = 'node'; @@ -62,11 +71,14 @@ public function socket_get($data) $cmd = $system->escapeshellcmd("$command " . $path . "/serveccda.js"); exec($cmd . " > /dev/null &"); } - sleep(2); // give cpu a rest - $result = socket_connect($socket, "127.0.0.1", "6661"); - if ($result === false) { // hmm something is amiss with service. user will likely try again. - error_log("Failed to start and connect to local ccdaservice server on port 6661"); - throw new CcdaServiceConnectionException("Connection Failed"); + sleep(5); // give cpu a rest + // now try to connect to the server + $result = socket_connect($socket, "127.0.0.1", (int)6661); + if ($result === false) { + $errorCode = socket_last_error($socket); + $errorMsg = socket_strerror($errorCode); + error_log("Socket connection error $errorCode: $errorMsg"); + throw new CcdaServiceConnectionException("Connection Failed: $errorMsg"); } } else { error_log("C-CDA Service is not enabled in Global Settings"); diff --git a/interface/patient_file/deleter.php b/interface/patient_file/deleter.php index cd3e9460f1e..d5b0b323f0c 100644 --- a/interface/patient_file/deleter.php +++ b/interface/patient_file/deleter.php @@ -237,12 +237,9 @@ function popup_close() { $res = sqlStatement("SELECT * FROM forms WHERE pid = ?", array($patient)); while ($row = sqlFetchArray($res)) { - row_modify( - "forms", - "deleted = 1", - "pid = '" . add_escape_custom($row['pid']) . - "' AND form_id = '" . add_escape_custom($row['form_id']) . "'" - ); + row_delete("forms", "pid = '" . add_escape_custom($row['pid']) . + "' AND form_id = '" . add_escape_custom($row['form_id']) . "'"); + row_delete("form_encounter", "pid = '" . add_escape_custom($row['pid']) . "'"); } // Delete all documents for the patient. diff --git a/src/Services/Cda/CdaComponentParseHelpers.php b/src/Services/Cda/CdaComponentParseHelpers.php new file mode 100644 index 00000000000..256302e9450 --- /dev/null +++ b/src/Services/Cda/CdaComponentParseHelpers.php @@ -0,0 +1,76 @@ + + * @copyright Copyright (c) 2025 Jerry Padgett + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Cda; + +use DOMDocument; +use DOMXPath; + +class CdaComponentParseHelpers +{ + private $dom; + private $xpath; + + public function __construct(string $rawXmlContent) + { + $this->dom = new DOMDocument(); + $this->dom->loadXML($rawXmlContent); + $this->xpath = new DOMXPath($this->dom); + + $namespaces = $this->dom->documentElement->lookupNamespaceURI(null); + if ($namespaces) { + $this->xpath->registerNamespace('ns', $namespaces); + } + } + + public function parseGuardianParticipant(): array + { + $guardianData = []; + + // Locate element with associatedEntity classCode="GUAR" + $participant = $this->xpath->query("//ns:participant[@typeCode='IND']/ns:associatedEntity[@classCode='GUAR']")->item(0); + if (!$participant) { + return $guardianData; // Return empty if no guardian found + } + + $addressNode = $this->xpath->query(".//ns:addr", $participant)->item(0); + if ($addressNode) { + $guardianData['address'] = [ + 'street' => $this->xpath->query(".//ns:streetAddressLine", $addressNode)->item(0)?->nodeValue ?? '', + 'city' => $this->xpath->query(".//ns:city", $addressNode)->item(0)?->nodeValue ?? '', + 'state' => $this->xpath->query(".//ns:state", $addressNode)->item(0)?->nodeValue ?? '', + 'postalCode' => $this->xpath->query(".//ns:postalCode", $addressNode)->item(0)?->nodeValue ?? '', + 'country' => $this->xpath->query(".//ns:country", $addressNode)->item(0)?->nodeValue ?? '', + ]; + } + + $telecomNodes = $this->xpath->query(".//ns:telecom", $participant); + $guardianData['contact'] = []; + foreach ($telecomNodes as $telecom) { + $use = $telecom->getAttribute('use'); + $value = str_replace('tel:', '', $telecom->getAttribute('value')); + $guardianData['contact'][$use] = $value; + } + + $nameNode = $this->xpath->query(".//ns:associatedPerson/ns:name", $participant)->item(0); + if ($nameNode) { + $guardianData['name'] = [ + 'given' => array_map( + fn($node) => $node->nodeValue, + iterator_to_array($this->xpath->query(".//ns:given", $nameNode)) + ), + 'family' => $this->xpath->query(".//ns:family", $nameNode)->item(0)?->nodeValue ?? '', + ]; + } + + return $guardianData; + } +} diff --git a/src/Services/Cda/CdaTemplateImportDispose.php b/src/Services/Cda/CdaTemplateImportDispose.php index dc4bb6b9c27..1fa38153eff 100644 --- a/src/Services/Cda/CdaTemplateImportDispose.php +++ b/src/Services/Cda/CdaTemplateImportDispose.php @@ -33,6 +33,7 @@ class CdaTemplateImportDispose protected $codeService; protected $userauthorized; + private int $currentEncounter; public function __construct() { @@ -90,7 +91,7 @@ public function InsertAllergies($allergy_array, $pid, CarecoordinationTable $car if ($revapprove == 1) { if ($value['resolved'] == 1) { if (!$allergy_enddate_value) { - $allergy_enddate_value = date('y-m-d'); + $allergy_enddate_value = date('Y-m-d'); } } else { $allergy_enddate_value = (null); @@ -187,7 +188,7 @@ public function InsertAllergies($allergy_array, $pid, CarecoordinationTable $car ? )"; $result = $appTable->zQuery($query, array($pid, - date('y-m-d H:i:s'), + date('Y-m-d H:i:s'), $allergy_begdate_value, $allergy_enddate_value, 'allergy', @@ -210,7 +211,7 @@ public function InsertAllergies($allergy_array, $pid, CarecoordinationTable $car reaction=? WHERE external_id=? AND type='allergy' AND pid=?"; $appTable->zQuery($q_upd_allergies, array($pid, - date('y-m-d H:i:s'), + date('Y-m-d H:i:s'), $allergy_begdate_value, $allergy_enddate_value, $value['list_code_text'], @@ -292,7 +293,7 @@ public function InsertCarePlan($care_plan_array, $pid, CarecoordinationTable $ca } $query_insert = "INSERT INTO `form_care_plan` (`id`,`pid`,`groupname`,`user`,`encounter`,`activity`,`code`,`codetext`,`description`,`date`,`care_plan_type`, `date_end`, `reason_code`, `reason_description`, `reason_date_low`, `reason_date_high`, `reason_status`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"; - $res = $appTable->zQuery($query_insert, array($newid, $pid, $_SESSION["authProvider"], $_SESSION["authUser"], $encounter_for_forms, 1, $value['code'], $value['text'], $value['description'], $plan_date_value, $value['plan_type'], $end_date, $value['reason_code'], $value['reason_code_text'], $low_date, $high_date, $value['reason_status'] ?? null)); + $res = $appTable->zQuery($query_insert, array($newid, $pid, $_SESSION["authProvider"], $_SESSION["authUser"], $encounter_for_forms, 1, $value['code'], $value['text'], $value['description'], $plan_date_value, $value['plan_type'], $end_date, $value['reason_code'] ?? '', $value['reason_code_text'] ?? '', $low_date, $high_date, $value['reason_status'] ?? null)); $forms_encounters[$encounter_for_forms] = ['enc' => $encounter_for_forms, 'form_id' => $newid, 'date' => $plan_date_value]; } @@ -305,6 +306,65 @@ public function InsertCarePlan($care_plan_array, $pid, CarecoordinationTable $ca } } + + /** + * @param $clinical_note_array + * @param $pid + * @param CarecoordinationTable $carecoordinationTable + * @param $revapprove + * @return void + */ + public function InsertClinicalNote($clinical_note_array, $pid, CarecoordinationTable $carecoordinationTable, $revapprove = 1): void + { + if (empty($clinical_note_array)) { + return; + } + $newid = ''; + $encounter_for_forms = null; + $forms_encounters = null; + $appTable = new ApplicationTable(); + $res = $appTable->zQuery("SELECT MAX(form_id) as largestId FROM `form_clinical_notes`"); + foreach ($res as $val) { + if ($val['largestId']) { + $newid = $val['largestId'] + 1; + } else { + $newid = 1; + } + } + foreach ($clinical_note_array as $key => $value) { + $plan_date_value = $value['date'] ? date("Y-m-d", $this->str_to_time($value['date'])) : null; + // encounters already created or should be created + if (empty($encounter_for_forms)) { + $encounter_for_forms = $this->findClosestEncounterWithForm(trim($plan_date_value), $pid, 'form_clinical_notes'); + if (empty($encounter_for_forms && empty($this->currentEncounter))) { + $query_sel_enc = "SELECT encounter FROM form_encounter WHERE date=? AND pid=?"; + $res_query_sel_enc = $appTable->zQuery($query_sel_enc, array(date('Y-m-d H:i:s'), $pid)); + if ($res_query_sel_enc->count() == 0) { + $res_enc = $appTable->zQuery("SELECT encounter FROM form_encounter WHERE pid=? ORDER BY id DESC LIMIT 1", array($pid)); + $res_enc_cur = $res_enc->current(); + $encounter_for_forms = $res_enc_cur['encounter']; + } else { + $encounter_for_forms = $this->currentEncounter; + /*foreach ($res_query_sel_enc as $value2) { + $encounter_for_forms = $value2['encounter']; + }*/ + } + } + } + $query_insert = "INSERT INTO `form_clinical_notes` (`form_id`,`pid`,`groupname`,`user`,`encounter`,`activity`,`code`,`codetext`,`description`,`date`,`clinical_notes_type`) VALUES (?,?,?,?,?,?,?,?,?,?,?)"; + $res = $appTable->zQuery($query_insert, array($newid, $pid, $_SESSION["authProvider"], $_SESSION["authUser"], $encounter_for_forms, 1, $value['code'], $value['text'], $value['description'], $plan_date_value, $value['plan_type'])); + + $forms_encounters[$encounter_for_forms] = ['enc' => $encounter_for_forms, 'form_id' => $newid, 'date' => $plan_date_value]; + } + + if (count($forms_encounters ?? []) > 0) { + foreach ($forms_encounters as $k => $form) { + $query = "INSERT INTO forms(date,encounter,form_name,form_id,pid,user,groupname,formdir) VALUES(?,?,?,?,?,?,?,?)"; + $appTable->zQuery($query, array(date('Y-m-d'), $k, 'Clinical Notes Form', $form['form_id'], $pid, $_SESSION["authUser"], $_SESSION["authProvider"], 'clinical_notes')); + } + } + } + /** * @param $proc_array * @param $pid @@ -603,11 +663,14 @@ public function InsertEncounter($enc_array, $pid, CarecoordinationTable $carecoo $catname = ''; $pc_catid = null; $pc_catid_default = 5; - $reason = null; + $reason = $value['reason'] ?? ''; if (!empty($value['code_text'] ?? null)) { $cat = explode('|', $value['code_text'] ?? null); $catname = trim($cat[0]); - $reason = trim($cat[1] ?? ''); + $catname = substr($catname, 0, 92); + if (empty($reason)) { + $reason = trim($cat[1] ?? ''); + } $pc_catid = sqlQuery("SELECT pc_catid FROM `openemr_postcalendar_categories` Where `pc_catname` = ?", array($catname))['pc_catid'] ?? ''; } if (empty($pc_catid) && !empty($catname)) { @@ -738,6 +801,7 @@ public function InsertEncounter($enc_array, $pid, CarecoordinationTable $carecoo $insertEX = "INSERT INTO external_encounters(ee_date,ee_pid,ee_provider_id,ee_facility_id,ee_encounter_diagnosis,ee_external_id) VALUES (?,?,?,?,?,?)"; $appTable->zQuery($insertEX, array($encounter_date_value, $pid, $provider_id, $facility_id, ($value['encounter_diagnosis_issue'] ?? null), ($value['extension'] ?? null))); } + $this->currentEncounter = $encounter_id; } /** @@ -1168,7 +1232,7 @@ public function InsertPrescriptions($pres_array, $pid, CarecoordinationTable $ca $res_q_sel_pres = $appTable->zQuery($q_sel_pres, array($pid, $value['extension'])); $res_q_sel_pres_cnt = $res_q_sel_pres->count(); } else { - // prevent bunch of duplicated prescriptions/medications + // prevent a bunch of duplicated prescriptions/medications $q_sel_pres_r = "SELECT * FROM `prescriptions` WHERE `patient_id` = ? AND `drug` = ?"; @@ -1194,8 +1258,8 @@ public function InsertPrescriptions($pres_array, $pid, CarecoordinationTable $ca , 'date_ended' => $value['enddate'] , 'active' => $active , 'drug' => $value['drug_text'] - , 'size' => $value['rate'] - , 'form' => $oidu_unit + , 'size' => $value['rate'] + , 'form' => $oidu_unit , 'dosage' => $value['dose'] , 'route' => $oid_route , 'unit' => $unit_option_id @@ -1210,7 +1274,9 @@ public function InsertPrescriptions($pres_array, $pid, CarecoordinationTable $ca } } while ($choice !== $yesContinue); } + $addMedication = false; if ((empty($value['extension']) && $res_q_sel_pres_r_cnt === 0) || ($res_q_sel_pres_cnt === 0)) { + $addMedication = true; $query = "INSERT INTO prescriptions ( patient_id, date_added, @@ -1307,6 +1373,26 @@ public function InsertPrescriptions($pres_array, $pid, CarecoordinationTable $ca 0, ($value['request_intent'] ?? null))); } + + // Medication additions + if ($addMedication) { + $sql = "INSERT INTO lists SET `type` = ?, `begdate` = ?, `enddate` = ?, `pid` = ?, + `title` = ?, `diagnosis` = ?, `date` = ?, `activity` = ?, `user` = ?, `groupname` = ?"; + $med = [ + 'medication', + $value['begdate'], + $value['enddate'], + $pid, + $value['drug_text'], + $value['drug_code'], + date('YmdHis'), + $active, + $provider_id, + 'Default' + ]; + $appTable->zQuery($sql, $med); + $addMedication = false; + } } } @@ -1346,7 +1432,7 @@ public function InsertMedicalProblem($med_pblm_array, $pid, CarecoordinationTabl if ($revapprove == 1) { if ($value['resolved'] == 1) { if (!$med_pblm_enddate_value) { - $med_pblm_enddate_value = date('y-m-d'); + $med_pblm_enddate_value = date('Y-m-d'); } } else { $med_pblm_enddate_value = (null); @@ -1405,7 +1491,7 @@ public function InsertMedicalProblem($med_pblm_array, $pid, CarecoordinationTabl ? )"; $result = $appTable->zQuery($query, array($pid, - date('y-m-d H:i:s'), + date('Y-m-d H:i:s'), $value['list_code'], $activity, $value['list_code_text'], @@ -1428,7 +1514,7 @@ public function InsertMedicalProblem($med_pblm_array, $pid, CarecoordinationTabl outcome=? WHERE external_id=? AND type='medical_problem' AND begdate=? AND diagnosis=? AND pid=?"; $appTable->zQuery($q_upd_med_pblm, array($pid, - date('y-m-d H:i:s'), + date('Y-m-d H:i:s'), $value['list_code'], $value['list_code_text'], $med_pblm_begdate_value, @@ -1451,6 +1537,7 @@ public function insertImportedUser($value, $create_user_name = false) { $appTable = new ApplicationTable(); $userName = ""; + $is_user = []; if (!empty($value['provider_fname'] ?? '')) { $value['provider_name'] = $value['provider_fname']; @@ -1460,10 +1547,12 @@ public function insertImportedUser($value, $create_user_name = false) } // so for those that don't use NPI's or npi was missed we'll take a look for user by name. - $is_user = sqlQuery( - "Select id From users Where fname = ? And lname = ?", - array($value['provider_name'] ?? null, $value['provider_family'] ?? null) - ); + if (!empty($value['provider_name']) && !empty($value['provider_family'])) { + $is_user = sqlQuery( + "Select id From users Where fname = ? And lname = ?", + array($value['provider_name'] ?? null, $value['provider_family'] ?? null) + ); + } if (empty($is_user['id'])) { $is_user = sqlQuery("Select id From users Where fname = ? And lname = ?", array('External', 'Provider')); } @@ -1502,13 +1591,14 @@ public function InsertLabResults($lab_results, $pid, CarecoordinationTable $care if (empty($lab_results)) { return; } - - $pro_name = xlt('External DX/Lab'); + $pro_name = xlt('External Lab'); if ($carecoordinationTable->is_qrda_import) { $pro_name = xlt('Qrda Lab'); } $appTable = new ApplicationTable(); foreach ($lab_results as $key => $value) { + $date = !empty($value['date'] ?? null) ? date("Y-m-d H:i:s", $this->str_to_time($value['date'])) : null; + $value['proc_text'] = $value['proc_text'] ?? (xl('Results') . ' ' . date("Y-m-d", $this->str_to_time($value['date']))); $query_select_pro = "SELECT * FROM procedure_providers WHERE name = ?"; $result_pro = $appTable->zQuery($query_select_pro, array($pro_name)); if ($result_pro->count() == 0) { @@ -1520,8 +1610,6 @@ public function InsertLabResults($lab_results, $pid, CarecoordinationTable $care $pro_id = $value1['ppid']; } } - - $date = !empty($value['date'] ?? null) ? date("Y-m-d H:i:s", $this->str_to_time($value['date'])) : null; // which encounter? $enc_id = $this->findClosestEncounter(trim($value['date']), $pid); if (empty($enc_id)) { @@ -1538,15 +1626,15 @@ public function InsertLabResults($lab_results, $pid, CarecoordinationTable $care if ($result_pt->count() == 0) { //procedure_type $query_insert_pt = 'INSERT INTO procedure_type(name,lab_id,procedure_code,procedure_type,activity,procedure_type_name) VALUES (?,?,?,?,?,?)'; - $result_pt = $appTable->zQuery($query_insert_pt, array($value['proc_text'], $pro_id, $value['proc_code'], 'ord', 1, 'laboratory_test')); + $result_pt = $appTable->zQuery($query_insert_pt, array($value['proc_text'], $pro_id, $value['proc_code'] ?? '', 'ord', 1, 'laboratory_test')); $res_pt_id = $result_pt->getGeneratedValue(); $query_update_pt = 'UPDATE procedure_type SET parent = ? WHERE procedure_type_id = ?'; $appTable->zQuery($query_update_pt, array($res_pt_id, $res_pt_id)); } - if (!empty($value['result'][0]['result_date']) && empty($date)) { + if (!empty($value['results'][0]['result_date']) && empty($date)) { // no order date so give result date - $date = date("Y-m-d H:i:s", $this->str_to_time(trim($value['result'][0]['result_date']))); + $date = date("Y-m-d H:i:s", $this->str_to_time(trim($value['results'][0]['result_date']))); } if (empty($date)) { // no order date make today @@ -1560,7 +1648,7 @@ public function InsertLabResults($lab_results, $pid, CarecoordinationTable $care //procedure_order_code $query_insert_poc = 'INSERT INTO procedure_order_code(procedure_order_id,procedure_order_seq,procedure_code,procedure_name,diagnoses,procedure_order_title,procedure_type) VALUES (?,?,?,?,?,?,?)'; - $result_poc = $appTable->zQuery($query_insert_poc, array($po_id, 1, $value['proc_code'], $value['proc_text'], '', 'laboratory_test', 'laboratory_test')); + $result_poc = $appTable->zQuery($query_insert_poc, array($po_id, 1, $value['proc_code'] ?? '', $value['proc_text'], '', 'laboratory_test', 'laboratory_test')); addForm($enc_id, $pro_name . '-' . $po_id, $po_id, 'procedure_order', $pid, $this->userauthorized); //procedure_report @@ -1568,7 +1656,7 @@ public function InsertLabResults($lab_results, $pid, CarecoordinationTable $care $result_pr = $appTable->zQuery($query_insert_pr, array($po_id, $date, $date, 'final', 'reviewed')); $res_id = $result_pr->getGeneratedValue(); - foreach ($value['result'] as $res) { + foreach ($value['results'] as $res) { //procedure_result $range = $res['result_range'] ?? ''; $unit = $res['result_unit'] ?? ''; diff --git a/src/Services/Cda/CdaTemplateParse.php b/src/Services/Cda/CdaTemplateParse.php index 663b1511910..027fa95613a 100644 --- a/src/Services/Cda/CdaTemplateParse.php +++ b/src/Services/Cda/CdaTemplateParse.php @@ -6,7 +6,7 @@ * @package OpenEMR * @link https://www.open-emr.org * @author Jerry Padgett - * @copyright Copyright (c) 2021 Jerry Padgett + * @copyright Copyright (c) 2021-2025 Jerry Padgett * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 */ @@ -23,15 +23,17 @@ class CdaTemplateParse private $codeService; private $currentOid; protected $is_qrda_import; + public $conditionedXmlContent; /** * @var \Symfony\Contracts\EventDispatcher\EventDispatcherInterface */ private $ed; - public function __construct() + public function __construct($conditionedXmlContent = "") { $this->templateData = []; + $this->conditionedXmlContent = $conditionedXmlContent; $this->is_qrda_import = false; $this->codeService = new CodeTypesService(); $this->ed = $GLOBALS['kernel']->getEventDispatcher(); @@ -39,7 +41,6 @@ public function __construct() public function parseCDAEntryComponents($components): array { - $components_oids = array( '2.16.840.1.113883.10.20.22.4.7' => 'allergy', '2.16.840.1.113883.10.20.22.2.6.1' => 'allergy', @@ -66,7 +67,8 @@ public function parseCDAEntryComponents($components): array '2.16.840.1.113883.10.20.22.2.56' => 'functionalCognitiveStatus', '1.3.6.1.4.1.19376.1.5.3.1.3.1' => 'referral', '2.16.840.1.113883.10.20.22.2.11.1' => 'dischargeMedications', - '2.16.840.1.113883.10.20.22.2.41' => 'dischargeSummary' + '2.16.840.1.113883.10.20.22.2.41' => 'dischargeSummary', + '2.16.840.1.113883.10.20.22.2.65' => 'fetchClinicalNoteData', ); $preParseEvent = new CDAPreParseEvent($components); @@ -367,6 +369,14 @@ public function fetchEncounterPerformed($entry): void if ($this->is_qrda_import) { $entry = $entry['act']['entryRelationship']; } + $parser = new CdaTextParser($this->conditionedXmlContent, "Imported Encounter Notes."); + $note = $parser->parseSectionByCode("46240-8"); + if (!empty($note)) { + $note_text = $parser->generateConsolidatedTextNote($note); + } else { + $note_text = ''; + } + if (!empty($entry['encounter']['effectiveTime']['value']) || !empty($entry['encounter']['effectiveTime']['low']['value'])) { $i = 1; if (!empty($this->templateData['field_name_value_array']['encounter'])) { @@ -381,6 +391,9 @@ public function fetchEncounterPerformed($entry): void $code_type = $entry['encounter']['code']['codeSystemName'] ?? '' ?: $entry['encounter']['code']['codeSystem'] ?? ''; $code_text = $entry['encounter']['code']['displayName'] ?? ''; $code = $this->codeService->resolveCode($entry['encounter']['code']['code'], $code_type, $code_text); + if ($code == '99211') { + $code['code_text'] = 'Office or other outpatient visit for evaluation.'; + } $this->templateData['field_name_value_array']['encounter'][$i]['code'] = $code['formatted_code']; $this->templateData['field_name_value_array']['encounter'][$i]['code_text'] = $code['code_text']; @@ -441,6 +454,7 @@ public function fetchEncounterPerformed($entry): void } } $this->templateData['field_name_value_array']['encounter'][$i]['encounter_discharge_code'] = $option ?? ''; + $this->templateData['field_name_value_array']['encounter'][$i]['reason'] = $note_text; $this->templateData['entry_identification_array']['encounter'][$i] = $i; } @@ -451,7 +465,7 @@ public function fetchEncounterPerformed($entry): void */ public function fetchMedicalProblemData($entry): void { - if (!empty($entry['act']['entryRelationship']['observation']['value']['code'])) { + if (!empty($entry['act']['entryRelationship']['observation']['value']['code']) || !empty($entry['act']['entryRelationship']['observation']['value']['codeSystem'])) { $i = 1; if (!empty($this->templateData['field_name_value_array']['lists1'])) { $i += count($this->templateData['field_name_value_array']['lists1']); @@ -461,11 +475,20 @@ public function fetchMedicalProblemData($entry): void $classification = 'concern'; } $this->templateData['field_name_value_array']['lists1'][$i]['subtype'] = $classification; - $code = $this->codeService->resolveCode( - $entry['act']['entryRelationship']['observation']['value']['code'], - ($entry['act']['entryRelationship']['observation']['value']['codeSystemName'] ?? '') ?: $entry['act']['entryRelationship']['observation']['value']['codeSystem'] ?? '', - $entry['act']['entryRelationship']['observation']['value']['displayName'] - ); + + if (!empty($entry['act']['entryRelationship']['observation']['value']['code'])) { + $code = $this->codeService->resolveCode( + $entry['act']['entryRelationship']['observation']['value']['code'], + ($entry['act']['entryRelationship']['observation']['value']['codeSystemName'] ?? '') ?: $entry['act']['entryRelationship']['observation']['value']['codeSystem'] ?? '', + $entry['act']['entryRelationship']['observation']['value']['displayName'] + ); + } elseif (!empty($entry['act']['entryRelationship']['observation']['value']['translation']['code'])) { + $code = $this->codeService->resolveCode( + $entry['act']['entryRelationship']['observation']['value']['translation']['code'], + ($entry['act']['entryRelationship']['observation']['value']['translation']['codeSystemName'] ?? '') ?: $entry['act']['entryRelationship']['observation']['value']['translation']['codeSystem'] ?? '', + $entry['act']['entryRelationship']['observation']['value']['translation']['displayName'] + ); + } $this->templateData['field_name_value_array']['lists1'][$i]['list_code'] = $code['formatted_code'] ?: $entry['act']['entryRelationship']['observation']['value']['code'] ?? ''; $this->templateData['field_name_value_array']['lists1'][$i]['list_code_text'] = $code['code_text'] ?: $entry['act']['entryRelationship']['observation']['value']['displayName'] ?? ''; @@ -487,13 +510,16 @@ public function fetchMedicalProblemData($entry): void */ public function fetchAllergyIntoleranceObservation($entry) { - if (!empty($entry['act']['entryRelationship']['observation']['participant']['participantRole']['playingEntity']['code']['code'])) { + $isNull = !empty($entry['act']['entryRelationship']['observation']['participant']['participantRole']['playingEntity']['code']['nullFlavor']); + if ( + !empty($entry['act']['entryRelationship']['observation']['participant']['participantRole']['playingEntity']['code']['code']) + || ($isNull && $entry['act']['effectiveTime']['low']['value']) + ) { $i = 1; // if there are already items here we want to add to them. if (!empty($this->templateData['field_name_value_array']['lists2'])) { $i += count($this->templateData['field_name_value_array']['lists2']); } - $this->templateData['field_name_value_array']['lists2'][$i]['type'] = 'allergy'; $this->templateData['field_name_value_array']['lists2'][$i]['extension'] = $entry['act']['id']['extension'] ?? ''; $this->templateData['field_name_value_array']['lists2'][$i]['begdate'] = $entry['act']['effectiveTime']['low']['value'] ?? null; @@ -579,6 +605,7 @@ public function fetchMedicationData($entry): void $this->templateData['field_name_value_array']['lists3'][$i]['request_type'] = $request_type; $this->templateData['field_name_value_array']['lists3'][$i]['extension'] = $entry['substanceAdministration']['id']['extension'] ?? null; $this->templateData['field_name_value_array']['lists3'][$i]['root'] = $entry['substanceAdministration']['id']['root'] ?? null; + $this->templateData['field_name_value_array']['lists3'][$i]['completion_status'] = $entry['substanceAdministration']['statusCode']['code'] ?? null; $this->templateData['field_name_value_array']['lists3'][$i]['begdate'] = date('Y-m-d'); if (!empty($entry['substanceAdministration']['effectiveTime'][0]['low']['value'])) { @@ -591,7 +618,8 @@ public function fetchMedicationData($entry): void $this->templateData['field_name_value_array']['lists3'][$i]['route'] = $entry['substanceAdministration']['routeCode']['code'] ?? null; $this->templateData['field_name_value_array']['lists3'][$i]['route_display'] = $entry['substanceAdministration']['routeCode']['displayName'] ?? null; - $this->templateData['field_name_value_array']['lists3'][$i]['dose'] = $entry['substanceAdministration']['doseQuantity']['value'] ?? null; + $this->templateData['field_name_value_array']['lists3'][$i]['dose'] = number_format((float)$entry['substanceAdministration']['doseQuantity']['value'], 2, '.', '') ?? null; + $this->templateData['field_name_value_array']['lists3'][$i]['dose_unit'] = $entry['substanceAdministration']['doseQuantity']['unit'] ?? null; $this->templateData['field_name_value_array']['lists3'][$i]['rate'] = $entry['substanceAdministration']['rateQuantity']['value'] ?? null; $this->templateData['field_name_value_array']['lists3'][$i]['rate_unit'] = $entry['substanceAdministration']['rateQuantity']['unit'] ?? null; @@ -611,6 +639,7 @@ public function fetchMedicationData($entry): void $this->templateData['field_name_value_array']['lists3'][$i]['provider_state'] = $entry['substanceAdministration']['performer']['assignedEntity']['addr']['state'] ?? ($entry['substanceAdministration']['entryRelationship'][1]['supply']['performer']['assignedEntity']['addr']['state'] ?? null); $this->templateData['field_name_value_array']['lists3'][$i]['provider_postalCode'] = $entry['substanceAdministration']['performer']['assignedEntity']['addr']['postalCode'] ?? ($entry['substanceAdministration']['entryRelationship'][1]['supply']['performer']['assignedEntity']['addr']['postalCode'] ?? null); $this->templateData['field_name_value_array']['lists3'][$i]['provider_country'] = $entry['substanceAdministration']['performer']['assignedEntity']['addr']['country']['value'] ?? ($entry['substanceAdministration']['entryRelationship'][1]['supply']['performer']['assignedEntity']['addr']['country'] ?? null); + $this->templateData['entry_identification_array']['lists3'][$i] = $i; } } @@ -1001,8 +1030,13 @@ public function fetchLabResultData($lab_result_data): void $this->templateData['field_name_value_array']['procedure_result'][$i]['results_extension'] = $value['observation']['id']['extension'] ?? null; $this->templateData['field_name_value_array']['procedure_result'][$i]['results_root'] = $value['observation']['id']['root'] ?? null; // @TODO code lookup here - $this->templateData['field_name_value_array']['procedure_result'][$i]['results_code'] = $value['observation']['code']['code'] ?? null; - $this->templateData['field_name_value_array']['procedure_result'][$i]['results_text'] = $value['observation']['code']['displayName'] ?? null; + $code = $this->codeService->resolveCode( + $value['observation']['code']['code'], + $value['observation']['code']['codeSystemName'] ?? 'LOINC', + $value['observation']['code']['displayName'] ?? '' + ); + $this->templateData['field_name_value_array']['procedure_result'][$i]['results_code'] = $code['code'] ?? null; + $this->templateData['field_name_value_array']['procedure_result'][$i]['results_text'] = $code['code_text'] ?? null; $this->templateData['field_name_value_array']['procedure_result'][$i]['results_date'] = $value['observation']['effectiveTime']['value'] ?? null; if ($value['observation']['value']['type'] == 'ST') { $this->templateData['field_name_value_array']['procedure_result'][$i]['results_value'] = $value['observation']['value']['_'] ?? null; @@ -1286,18 +1320,54 @@ public function encounter($component) public function carePlan($component) { - if ($this->currentOid != '2.16.840.1.113883.10.20.22.2.58') { + if ($this->currentOid != '2.16.840.1.113883.10.20.22.2.58' && $this->currentOid != "2.16.840.1.113883.10.20.22.2.10") { $component['section']['text'] = null; } if (!empty($component['section']['entry'][0])) { foreach ($component['section']['entry'] as $key => $value) { $this->fetchCarePlanData($value, $component['section']['text']); } + } elseif (empty($component['section']['entry']) && !empty($component['section']['text'])) { + $this->fetchCarePlanNote($component['section']); } else { $this->fetchCarePlanData($component['section']['entry'] ?? null, $component['section']['text']); } } + public function fetchCarePlanNote($section): void + { + $plan_type = 'plan_of_care'; + // Care Plan Note + if ($section['code']['code'] == '18776-5') { + $parser = new CdaTextParser($this->conditionedXmlContent); + $note = $parser->parseSectionByCode("18776-5"); + if (empty($note)) { + return; + } + $note_text = $parser->generateConsolidatedTextNote($note); + } else { + return; + } + + $i = 1; + if (!empty($this->templateData['field_name_value_array']['care_plan_notes'])) { + $i += count($this->templateData['field_name_value_array']['care_plan_notes']); + } + + $code = $this->codeService->resolveCode( + $section['code']['code'] ?? '', + $section['code']['codeSystemName'] ?? null, + $section['code']['displayName'] ?? '' + ); + $this->templateData['field_name_value_array']['care_plan'][$i]['plan_type'] = $plan_type; + $this->templateData['field_name_value_array']['care_plan'][$i]['date'] = date("Y-m-d"); + $this->templateData['field_name_value_array']['care_plan'][$i]['code'] = $code['formatted_code']; + $this->templateData['field_name_value_array']['care_plan'][$i]['code_text'] = $code['code_text']; + $this->templateData['field_name_value_array']['care_plan'][$i]['description'] = $note_text; + + $this->templateData['entry_identification_array']['care_plan'][$i] = $i; + } + public function fetchCarePlanData($entry, $section_text = '') { $plan_type = 'plan_of_care'; @@ -1519,6 +1589,58 @@ public function fetchCarePlanData($entry, $section_text = '') } } + public function clinicalNotes($component) + { + if (!empty($component['section']['entry'][0])) { + foreach ($component['section']['entry'] as $key => $value) { + $this->fetchClinicalNoteData($value); + } + } else { + $this->fetchClinicalNoteData($component['section']['entry'] ?? null); + } + } + + public function fetchClinicalNoteData(): void + { + $parser = new ProgressNoteParser(); + $progressNotes = $parser->parseProgressNotes($this->conditionedXmlContent); + $i = 1; + if (isset($progressNotes['progress_notes']) && is_array($progressNotes['progress_notes'])) { + foreach ($progressNotes['progress_notes'] as $i => $note) { + $this->templateData['field_name_value_array']['clinical_notes'][$i]['plan_type'] = $note['plan_type'] ?? ''; + $this->templateData['field_name_value_array']['clinical_notes'][$i]['date'] = $note['effective_time'] ?? ''; + $this->templateData['field_name_value_array']['clinical_notes'][$i]['code'] = ($progressNotes['section_metadata']['codeSystemName'] ?? '') . ':' . $progressNotes['section_metadata']['code'] ?? ''; + $this->templateData['field_name_value_array']['clinical_notes'][$i]['code_text'] = $progressNotes['section_metadata']['displayName'] ?? ''; + $this->templateData['field_name_value_array']['clinical_notes'][$i]['code_system'] = $progressNotes['section_metadata']['codeSystemName'] ?? ''; + if (isset($note['author_details'])) { + $author = $note['author_details']; + $this->templateData['field_name_value_array']['clinical_notes'][$i]['author_name'] = $author['name'] ?? ''; + if (isset($author['wp_address'])) { + $this->templateData['field_name_value_array']['clinical_notes'][$i]['author_address_street'] = $note['author_details']['wp_address']['street'] ?? ''; + $this->templateData['field_name_value_array']['clinical_notes'][$i]['author_address_city'] = $note['author_details']['wp_address']['city'] ?? ''; + $this->templateData['field_name_value_array']['clinical_notes'][$i]['author_address_state'] = $note['author_details']['wp_address']['state'] ?? ''; + $this->templateData['field_name_value_array']['clinical_notes'][$i]['author_address_postal_code'] = $note['author_details']['wp_address']['postalCode'] ?? ''; + $this->templateData['field_name_value_array']['clinical_notes'][$i]['author_address_country'] = $note['author_details']['wp_address']['country'] ?? ''; + // Populate concatenated address + $this->templateData['field_name_value_array']['clinical_notes'][$i]['author_address'] = implode(', ', array_filter([ + $note['author_details']['wp_address']['street'] ?? '', + $note['author_details']['wp_address']['city'] ?? '', + $note['author_details']['wp_address']['state'] ?? '', + $note['author_details']['wp_address']['postalCode'] ?? '', + $note['author_details']['wp_address']['country'] ?? '', + ])); + } + $this->templateData['field_name_value_array']['clinical_notes'][$i]['author_phone'] = $author['wp_phone'] ?? ''; + $addAuthor = "AUTHOR: {$author['name']} {$this->templateData['field_name_value_array']['clinical_notes'][$i]['author_address']}\nPHONE: {$author['wp_phone']}\n\n"; + $this->templateData['field_name_value_array']['clinical_notes'][$i]['description'] = $addAuthor . $note['content'] ?? ''; + } + $this->templateData['entry_identification_array']['clinical_notes'][$i] = $i; + $i++; + } + $status = 'done'; + } + } + public function functionalCognitiveStatus($component) { if ($this->currentOid != '2.16.840.1.113883.10.20.22.2.56') { diff --git a/src/Services/Cda/CdaTextParser.php b/src/Services/Cda/CdaTextParser.php new file mode 100644 index 00000000000..b4a939aeef3 --- /dev/null +++ b/src/Services/Cda/CdaTextParser.php @@ -0,0 +1,130 @@ + + * @copyright Copyright (c) 2025 Jerry Padgett + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Cda; + +use DOMDocument; +use DOMElement; +use DOMXPath; + +class CdaTextParser +{ + private $xml; + private mixed $title; + + public function __construct($xmlContent, $title = "Imported CarePlan Notes.") + { + $this->title = $title; + $dom = new DOMDocument(); + $dom->loadXML($xmlContent); + $this->xml = $dom; + } + + /** + * Parse the section with a specific code to extract notes. + * + * @param string $sectionCode Section code to search for. + * @return array Extracted notes. + */ + public function parseSectionByCode($sectionCode): array + { + $notes = []; + $xpath = new DOMXPath($this->xml); + + // Register namespaces + $namespaces = $this->xml->documentElement->lookupNamespaceURI(null); + if ($namespaces) { + $xpath->registerNamespace('ns', $namespaces); // 'ns' is a generic prefix + } + + // use namespace prefix if present + $query = $namespaces ? "//ns:section[ns:code[@code='{$sectionCode}']]" : "//section[code[@code='{$sectionCode}']]"; + $sections = $xpath->query($query); + + foreach ($sections as $section) { + $list = $xpath->query(".//ns:list | .//list", $section)->item(0); + if ($list) { + foreach ($list->getElementsByTagName("item") as $item) { + $id = $item->getAttribute("ID") ?: "No ID"; + $caption = $item->getElementsByTagName("caption")->item(0)?->textContent ?: "No Caption"; + $content = $this->extractItemContent($item); + + $notes[] = [ + 'id' => $id, + 'caption' => $caption, + 'content' => $content, + ]; + } + } + } + + return $notes; + } + + /** + * Extract all text content from an , including nested lists. + * + * @param DOMElement $item + * @return string Extracted content. + */ + private function extractItemContent(DOMElement $item, int $level = 0): string + { + $contentLines = []; + $indent = str_repeat(" ", $level); // Indentation for nested content + + foreach ($item->childNodes as $child) { + if ($child->nodeType === XML_TEXT_NODE) { + $text = trim(preg_replace('/\s+/', ' ', $child->nodeValue)); // Normalize spaces + if ($text !== '') { + $contentLines[] = $indent . $text; + } + } elseif ($child->nodeType === XML_ELEMENT_NODE) { + if ($child->tagName === 'list') { + // Recursive parsing for nested lists + foreach ($child->getElementsByTagName("item") as $nestedItem) { + $nestedContent = $this->extractItemContent($nestedItem, $level + 1); + if ($nestedContent) { + $contentLines[] = $nestedContent; + } + } + } else { + $text = trim(preg_replace('/\s+/', ' ', $child->textContent)); // Normalize spaces + if ($text !== '') { + $contentLines[] = $indent . $text; + } + } + } + } + + return implode("\n", array_filter($contentLines)); + } + + /** + * Generate textareas from parsed notes. + * + * @param array $notes Parsed notes. + * @return string Generated HTML. + */ + public function generateConsolidatedTextNote($notes): string + { + if (empty($notes)) { + return ''; + } + $text = "\n{$this->title}\n"; + foreach ($notes as $index => $note) { + $index++; + $text .= "Item {$index} {$note['id']} {$note['caption']}\n"; + $text .= $note['content']; + $text .= "\n"; + } + return $text; + } +} diff --git a/src/Services/Cda/ProgressNoteParser.php b/src/Services/Cda/ProgressNoteParser.php new file mode 100644 index 00000000000..75ff7f88a3e --- /dev/null +++ b/src/Services/Cda/ProgressNoteParser.php @@ -0,0 +1,172 @@ + + * @copyright Copyright (c) 2025 Jerry Padgett + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Cda; + +use DOMDocument; +use DOMXPath; + +class ProgressNoteParser +{ + private $xml; + + public function __construct() + { + } + + /** + * Parse the progress note section with templateId OID. + * + * @param string $xmlContent XML content. + * @return array Parsed notes with metadata. + */ + public function parseProgressNotes($xmlContent): array + { + $dom = new DOMDocument(); + $dom->loadXML($xmlContent); + $xpath = new DOMXPath($dom); + $this->xml = $dom; + + $namespaces = $this->xml->documentElement->lookupNamespaceURI(null); + if ($namespaces) { + $xpath->registerNamespace('ns', $namespaces); + } + + $progressNotes = []; + // Locate the
using the OID + $section = $xpath->query("//ns:component/ns:section[ns:templateId[@root='2.16.840.1.113883.10.20.22.2.65']]")->item(0); + + if (!$section) { + return []; + } + // Extract section metadata + $sectionMetadata = $this->extractSectionMetadata($xpath, $section); + // Extract nodes and map them by ID + $itemMap = []; + $items = $xpath->query(".//ns:text/ns:list/ns:item", $section); + foreach ($items as $item) { + $id = $item->getAttribute("ID"); + if ($id) { + $itemMap["#" . $id] = $this->extractItemContent($item); + } + } + + // Process nodes + $entries = $xpath->query(".//ns:entry", $section); + foreach ($entries as $entry) { + $referenceNode = $xpath->query(".//ns:text/ns:reference", $entry)->item(0); + $referenceId = $referenceNode?->getAttribute("value"); + $effectiveTime = $xpath->query(".//ns:effectiveTime", $entry)->item(0)?->getAttribute("value") ?? "Unknown"; + $authorDetails = $this->extractAuthorDetails($xpath, $entry); + + $noteContent = $itemMap[$referenceId] ?? "Referenced content not found."; + $progressNotes[] = [ + 'plan_type' => 'progress_note', + 'reference_id' => $referenceId, + 'effective_time' => $effectiveTime, + 'author_details' => $authorDetails, + 'content' => $noteContent, + ]; + } + + return [ + 'section_metadata' => $sectionMetadata, + 'progress_notes' => $progressNotes, + ]; + } + + /** + * Extract metadata from the section. + * + * @param DOMXPath $xpath + * @param \DOMElement $section + * @return array Section metadata. + */ + private function extractSectionMetadata(DOMXPath $xpath, $section): array + { + return [ + 'code' => $xpath->query(".//ns:code", $section)->item(0)?->getAttribute("code") ?? null, + 'codeSystemName' => $xpath->query(".//ns:code", $section)->item(0)?->getAttribute("codeSystemName") ?? null, + 'displayName' => $xpath->query(".//ns:code", $section)->item(0)?->getAttribute("displayName") ?? null, + ]; + } + + /** + * Extract text content from an . + * + * @param \DOMElement $item + * @return string Extracted content. + */ + private function extractItemContent($item): string + { + $contentLines = []; + foreach ($item->childNodes as $child) { + $child->textContent = (string)str_replace(array("\n\n\n\n", "\n\n", "\r\r", "\r\r\r\r"), "\n", $child->textContent); + $text = trim($child->textContent); + if ($text) { + $contentLines[] = $text; + } + } + + return implode("\n", $contentLines); + } + + /** + * Extract author details from an . + * + * @param DOMXPath $xpath + * @param \DOMElement $entry + * @return array Author details including WP address and phone number. + */ + private function extractAuthorDetails(DOMXPath $xpath, $entry): array + { + $authorDetails = [ + 'name' => 'Unknown Author', + 'wp_address' => [ + 'street' => null, + 'city' => null, + 'state' => null, + 'postalCode' => null, + 'country' => null, + ], + 'wp_phone' => 'Unknown Phone', + ]; + // Extract author name + $authorNode = $xpath->query(".//ns:author/ns:assignedAuthor/ns:assignedPerson/ns:name", $entry)->item(0); + if ($authorNode) { + $given = $xpath->query(".//ns:given", $authorNode)->item(0)?->nodeValue ?? ""; + $family = $xpath->query(".//ns:family", $authorNode)->item(0)?->nodeValue ?? ""; + $authorDetails['name'] = trim("$given $family"); + $authorDetails['given'] = trim("$given"); + $authorDetails['family'] = trim("$family"); + } + // Extract WP address + $wpAddressNode = $xpath->query(".//ns:author/ns:assignedAuthor/ns:addr[@use='WP']", $entry)->item(0); + if ($wpAddressNode) { + $authorDetails['wp_address'] = [ + 'street' => $xpath->query(".//ns:streetAddressLine", $wpAddressNode)->item(0)?->textContent ?? null, + 'city' => $xpath->query(".//ns:city", $wpAddressNode)->item(0)?->textContent ?? null, + 'state' => $xpath->query(".//ns:state", $wpAddressNode)->item(0)?->textContent ?? null, + 'postalCode' => $xpath->query(".//ns:postalCode", $wpAddressNode)->item(0)?->textContent ?? null, + 'country' => $xpath->query(".//ns:country", $wpAddressNode)->item(0)?->textContent ?? null, + ]; + } + // Extract WP phone + $wpPhoneNode = $xpath->query(".//ns:author/ns:assignedAuthor/ns:telecom[@use='WP']", $entry)->item(0); + if ($wpPhoneNode) { + $rawPhone = $wpPhoneNode->getAttribute("value"); + $cleanPhone = str_replace("tel:", "", $rawPhone); + $authorDetails['wp_phone'] = $cleanPhone; + } + + return $authorDetails; + } +}