Clinic notes duplicate notes on fetch (#5950)
[openemr.git] / src / Services / ClinicalNotesService.php
blob62b0ba7a1431f7b65e0fe3df497805489b3813f4
1 <?php
3 /**
4 * ClinicalNotesService.php
5 * @package openemr
6 * @link http://www.open-emr.org
7 * @author Stephen Nielson <stephen@nielson.org>
8 * @copyright Copyright (c) 2021 Stephen Nielson <stephen@nielson.org>
9 * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3
12 namespace OpenEMR\Services;
14 use OpenEMR\Common\Database\QueryUtils;
15 use OpenEMR\Common\Uuid\UuidRegistry;
16 use OpenEMR\Services\Search\FhirSearchWhereClauseBuilder;
17 use OpenEMR\Services\Search\ISearchField;
18 use OpenEMR\Services\Search\SearchFieldException;
19 use OpenEMR\Services\Search\TokenSearchField;
20 use OpenEMR\Services\Search\TokenSearchValue;
21 use OpenEMR\Validators\ProcessingResult;
23 class ClinicalNotesService extends BaseService
25 const TABLE_NAME = "form_clinical_notes";
27 const ACTIVITY_ACTIVE = 1;
28 const ACTIVITY_INACTIVE = 0;
30 public function __construct()
32 parent::__construct(self::TABLE_NAME);
33 UuidRegistry::createMissingUuidsForTables(['form_clinical_notes']);
36 /**
37 * Returns a list of records matching the search criteria.
38 * Search criteria is conveyed by array where key = field/column name, value is an ISearchField
39 * If an empty array of search criteria is provided, all records are returned.
41 * The search will grab the intersection of all possible values if $isAndCondition is true, otherwise it returns
42 * the union (logical OR) of the search.
44 * More complicated searches with various sub unions / intersections can be accomplished through a CompositeSearchField
45 * that allows you to combine multiple search clauses on a single search field.
47 * @param ISearchField[] $search Hashmap of string => ISearchField where the key is the field name of the search field
48 * @param bool $isAndCondition Whether to join each search field with a logical OR or a logical AND.
49 * @return ProcessingResult The results of the search.
51 public function search($search, $isAndCondition = true)
53 // because we can have two clinical note table options (one from contrib etc), we will return an empty search
54 // result for now if the table does not conform to our CORE clinical_notes
55 $fields = $this->getFields();
56 $processingResult = new ProcessingResult();
57 if (array_search('code', $fields) === false) {
58 // there is no data right now for the other form so we leave it be.
59 return $processingResult;
62 // we leave status to be current, if we ever support entered-in-error, or superseded we can do that here.
63 try {
64 $sql = "
65 SELECT
66 notes.id
67 ,notes.uuid AS uuid
68 ,notes.activity
69 ,notes.date
70 ,notes.code
71 ,notes.codetext
72 ,notes.description
73 ,notes.external_id
74 ,notes.clinical_notes_type
75 ,notes.note_related_to
76 ,notes.clinical_notes_category
77 ,lo_category.category_code
78 ,lo_category.category_title
79 ,patients.pid
80 ,patients.puuid
81 ,encounters.eid
82 ,encounters.euuid
83 ,encounters.encounter_date
84 ,users.username
85 ,users.user_uuid
86 ,users.npi
87 ,users.physician_type
88 FROM
90 select
92 ,uuid
93 ,activity
94 ,`date`
95 ,`code`
96 ,codetext
97 ,`description`
98 ,external_id
99 ,clinical_notes_type
100 ,note_related_to
101 ,clinical_notes_category
102 ,form_id
103 ,user
104 FROM
105 form_clinical_notes
106 ) notes
107 JOIN (
108 SELECT
109 id AS form_id,
110 encounter
111 ,pid AS form_pid
112 FROM
113 forms
114 ) forms ON forms.form_id = notes.form_id
115 LEFT JOIN (
116 select
117 encounter AS eid
118 ,uuid AS euuid
119 ,`date` AS encounter_date
120 FROM
121 form_encounter
122 ) encounters ON encounters.eid = forms.encounter
123 LEFT JOIN
125 SELECT
126 uuid AS puuid
127 ,pid
128 FROM patient_data
129 ) patients ON forms.form_pid = patients.pid
130 LEFT JOIN
132 SELECT
133 uuid AS user_uuid
134 ,username
135 ,id AS uid
136 ,npi
137 ,physician_type
138 FROM
139 users
140 ) users ON notes.`user` = users.username
141 LEFT JOIN
143 SELECT
144 notes AS category_code
145 ,title AS category_title
146 ,option_id
147 FROM
148 list_options
149 WHERE
150 list_id = 'Clinical_Note_Category'
151 ) lo_category ON notes.clinical_notes_category = lo_category.option_id
153 $whereClause = FhirSearchWhereClauseBuilder::build($search, $isAndCondition);
155 $sql .= $whereClause->getFragment();
156 $sqlBindArray = $whereClause->getBoundValues();
158 $statementResults = QueryUtils::sqlStatementThrowException($sql, $sqlBindArray);
159 while ($row = sqlFetchArray($statementResults)) {
160 $resultRecord = $this->createResultRecordFromDatabaseResult($row);
161 $processingResult->addData($resultRecord);
163 } catch (SearchFieldException $exception) {
164 $processingResult->setValidationMessages([$exception->getField() => $exception->getMessage()]);
166 return $processingResult;
169 protected function createResultRecordFromDatabaseResult($row)
171 // TODO: @adunsulag this is just for testing until we can figure out the right uuid schematic
172 if (!empty($row['code'])) {
173 $row['code'] = $this->addCoding($row['code']);
175 return parent::createResultRecordFromDatabaseResult($row);
178 public function getUuidFields(): array
180 return ['uuid', 'puuid', 'euuid', 'user_uuid'];
184 * Sets the activity status flag for the given form_clinical_notes record to either be active(1) or inactive(0).
185 * @param $clinicalNoteId The unique record id for the form_clinical_notes table
186 * @param $pid The unique patient pid from the patient_data table
187 * @param $encounter The unique encounter id from the form_encounters table
188 * @param $activity The activity status: active(1) or inactive(0).
190 public function setActivityForClinicalRecord($clinicalNoteId, $pid, $encounter, $activity)
192 $sql = "UPDATE `form_clinical_notes` SET activity = ? WHERE id=? AND pid = ? AND encounter = ?";
193 $bindings = array($activity, $clinicalNoteId, $pid, $encounter);
194 QueryUtils::sqlStatementThrowException($sql, $bindings);
198 * Given a form id remove all of the clinical note records connected to that form.
199 * @param $formId The unique id from the forms table
200 * @param $pid The unique patient pid from the patient_data table
201 * @param $encounter The unique encounter id from the form_encounters table
203 public function clearClinicalRecordsForForm($formId, $pid, $encounter)
205 QueryUtils::sqlStatementThrowException(
206 "DELETE FROM `form_clinical_notes` WHERE form_id=? AND pid = ? AND encounter = ?",
207 array($formId, $pid, $encounter)
211 public function getClinicalRecordNoteById($id)
213 $sql = "select * from `form_clinical_notes` WHERE id = ? ";
214 $records = QueryUtils::fetchRecords($sql, [$id]);
215 if (!empty($records)) {
216 return $records[0];
218 return null;
221 public function createClinicalNotesParentForm($pid, $encounter, $userauthorized)
223 $largestId = QueryUtils::fetchSingleValue("SELECT COALESCE(MAX(form_id), 0) as largestId FROM `form_clinical_notes`", 'largestId');
225 if ($largestId > 0) {
226 $form_id = $largestId + 1;
227 } else {
228 $form_id = 1;
231 addForm($encounter, "Clinical Notes Form", $form_id, "clinical_notes", $pid, $userauthorized);
232 return $form_id;
235 public function saveArray(array $record)
237 $form_id = $record['form_id'] ?? null;
238 $id = $record['id'] ?? null;
239 $pid = $record['pid'] ?? null;
240 $encounter = $record['encounter'] ?? null;
241 $userauthorized = $record['authorized'] ?? null;
242 $existingRecord = [];
245 if (empty($form_id) || empty($pid) || empty($encounter) || empty($record) || $userauthorized === null) {
246 throw new \InvalidArgumentException("Record, form_id, pid, authorized and encounter must be populated");
249 unset($record['id']);
250 // we grab the existing record so we can populate the uuid if necessary
251 if (isset($id)) {
252 $existingRecord = $this->getClinicalRecordNoteById($id);
255 if (empty($form_id)) {
256 $largestId = QueryUtils::fetchSingleValue("SELECT COALESCE(MAX(form_id), 0) as largestId FROM `form_clinical_notes`", 'largestId');
258 if ($largestId > 0) {
259 $record['form_id'] = $largestId + 1;
260 } else {
261 $record['form_id'] = 1;
264 addForm($encounter, "Clinical Notes Form", $record['form_id'], "clinical_notes", $pid, $userauthorized);
266 if (empty($existingRecord['uuid'])) {
267 $record['uuid'] = (new UuidRegistry(['table_name' => 'form_clinical_notes']))->createUuid();
270 $keys = array_keys($record);
271 $setValues = array_map(function ($val) {
272 return $val . " = ? ";
273 }, $keys);
274 if (!empty($id)) {
275 $sql = "UPDATE " . self::TABLE_NAME . " SET " . implode(", ", $setValues) . " WHERE id = ? ";
276 $bindValues = array_values($record);
277 $bindValues[] = $id;
278 QueryUtils::sqlStatementThrowException($sql, $bindValues);
279 } else {
280 $sql = "INSERT INTO " . self::TABLE_NAME . " SET " . implode(", ", $setValues);
281 $bindValues = array_values($record);
282 $recordId = QueryUtils::sqlInsert($sql, $bindValues);
283 $record['id'] = $recordId;
285 // if we want the id&uuid back we need to return the record here
286 return $record;
289 public function getClinicalNoteIdsForPatientForm(int $formid, $pid, $encounter)
291 if (empty($formid) || empty($pid) || empty($encounter)) {
292 throw new \InvalidArgumentException("formid, pid, and encounter must all be populated");
295 $sql = "SELECT id FROM `form_clinical_notes` WHERE `form_id`=? AND `pid` = ? AND `encounter` = ?";
296 return QueryUtils::fetchTableColumn($sql, 'id', array($formid, $pid, $encounter));
300 * Retrieve all of the clinical notes for a given patient
301 * @param $pid
302 * @return ProcessingResult
304 public function getClinicalNotesForPatient($pid): ProcessingResult
306 $search['pid'] = new TokenSearchField('pid', new TokenSearchValue($pid));
307 return $this->search($search);
310 public function getClinicalNotesForPatientForm(int $formid, $pid, $encounter)
312 if (empty($formid) || empty($pid)) {
313 throw new \InvalidArgumentException("formid, and pid must all be populated");
316 $sql = "SELECT fcn.*, lo_category.title AS category_title, lo_category.notes AS category_code
317 FROM `form_clinical_notes` fcn
318 LEFT JOIN list_options lo_category ON lo_category.list_id = `Clinical_Notes_Category` AND lo_category.option_id = fcn.clinical_notes_category
319 LEFT JOIN list_options lo_type ON lo_type.list_id = `Clinical_Notes_Type` AND lo_type.option_id = fcn.clinical_notes_type
320 WHERE fcn.`form_id`=? AND fcn.`pid` = ? AND fcn.`encounter` = ?";
321 return QueryUtils::fetchRecords($sql, array($formid, $pid, $encounter));
324 public function deleteClinicalNoteRecordForPatient($recordId, $pid, $encounter)
326 $sql = "DELETE FROM `form_clinical_notes` WHERE id = ? AND pid= ? AND encounter = ?";
327 QueryUtils::sqlStatementThrowException($sql, [$recordId, $pid, $encounter]);
331 * Given a code (with or without LOINC prefix) determine if its a valid clinical note code that this service can
332 * respond to.
333 * @param $code string
334 * @return bool true if the code is valid, false otherwise
336 public function isValidClinicalNoteCode($code)
338 // make it a LOINC code
339 if (strpos($code, ":") === false) {
340 $code = "LOINC:" . $code;
342 $listService = new ListService();
343 $options = $listService->getOptionsByListName('Clinical_Note_Type', ['notes' => $code]);
344 return !empty($options);
347 public function getClinicalNoteTypes()
349 $listService = new ListService();
350 $options = $listService->getOptionsByListName('Clinical_Note_Type');
351 return $this->getListAsSelectList($options);
354 public function getClinicalNoteCategories()
356 $listService = new ListService();
357 $options = $listService->getOptionsByListName('Clinical_Note_Category');
358 return $this->getListAsSelectList($options);
361 private function getListAsSelectList($optionsList)
363 if (empty($optionsList)) {
364 return [];
367 $selectList = [];
368 foreach ($optionsList as $option) {
369 $selectList[] = ['value' => $option['option_id'], 'code' => $option['notes'], 'title' => $option['title']];
371 return $selectList;