his_emr_api_lab 1.1.28 → 1.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/controllers/lab/orders_controller.rb +51 -0
- data/app/controllers/lab/users_controller.rb +32 -0
- data/app/services/lab/json_web_token_service.rb +20 -0
- data/app/services/lab/lims/api/rest_api.rb +7 -0
- data/app/services/lab/lims/pull_worker.rb +30 -5
- data/app/services/lab/metadata.rb +1 -0
- data/app/services/lab/orders_service.rb +96 -2
- data/app/services/lab/results_service.rb +1 -0
- data/app/services/lab/user_service.rb +62 -0
- data/config/routes.rb +12 -1
- data/lib/lab/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: bc253a7a3dc9dcee98dde9b842f2c390571d3ca3fe2a677b733444a091413900
|
4
|
+
data.tar.gz: 105357ada270cc5c735fa144401b357cb362867ef9684997cddfe851e7e185d9
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4c1f681d9ecc0f6f14ae6cdcf8fb7cdfe91995a3961636825eff22bb31654cb2b2f10a11ce9f090446022c9d6ee5b8f49029cab14d8454b52c3d372856daff71
|
7
|
+
data.tar.gz: ae270f2c93ae6ba49aed7122e8f92f3ef7d1a250df3101395e9bffe610b990072cc6d06ef5a0fa1560b2e2ff7fd3abe6f3fb293a371965e25664f26ea5416cb8
|
@@ -2,6 +2,9 @@
|
|
2
2
|
|
3
3
|
module Lab
|
4
4
|
class OrdersController < ApplicationController
|
5
|
+
skip_before_action :authenticate, only: %i[order_status order_result]
|
6
|
+
before_action :authenticate_request, only: %i[order_status order_result]
|
7
|
+
|
5
8
|
def create
|
6
9
|
order_params_list = params.require(:orders)
|
7
10
|
orders = order_params_list.map do |order_params|
|
@@ -28,11 +31,59 @@ module Lab
|
|
28
31
|
render json: OrdersSearchService.find_orders(filters)
|
29
32
|
end
|
30
33
|
|
34
|
+
def verify_tracking_number
|
35
|
+
tracking_number = params.require(:accession_number)
|
36
|
+
render json: { exists: OrdersService.check_tracking_number(tracking_number) }, status: :ok
|
37
|
+
end
|
38
|
+
|
31
39
|
def destroy
|
32
40
|
OrdersService.void_order(params[:id], params[:reason])
|
33
41
|
Lab::VoidOrderJob.perform_later(params[:id])
|
34
42
|
|
35
43
|
render status: :no_content
|
36
44
|
end
|
45
|
+
|
46
|
+
def order_status
|
47
|
+
order_params = params.permit(:tracking_number, :status, :status_time, :comments)
|
48
|
+
OrdersService.update_order_status(order_params)
|
49
|
+
render json: { message: "Status for order #{order_params['tracking_number']} successfully updated" }, status: :ok
|
50
|
+
end
|
51
|
+
|
52
|
+
def order_result
|
53
|
+
params.permit!
|
54
|
+
order_params = params[:data].to_h
|
55
|
+
OrdersService.update_order_result(order_params)
|
56
|
+
render json: { message: 'Results processed successfully' }, status: :ok
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def authenticate_request
|
62
|
+
header = request.headers['Authorization']
|
63
|
+
content = header.split(' ')
|
64
|
+
auth_scheme = content.first
|
65
|
+
unless header
|
66
|
+
errors = ['Authorization token required']
|
67
|
+
render json: { errors: errors }, status: :unauthorized
|
68
|
+
return false
|
69
|
+
end
|
70
|
+
unless auth_scheme == 'Bearer'
|
71
|
+
errors = ['Authorization token bearer scheme required']
|
72
|
+
render json: { errors: errors }, status: :unauthorized
|
73
|
+
return false
|
74
|
+
end
|
75
|
+
|
76
|
+
process_token(content.last)
|
77
|
+
end
|
78
|
+
|
79
|
+
def process_token(token)
|
80
|
+
browser = Browser.new(request.user_agent)
|
81
|
+
decoded = Lab::JsonWebTokenService.decode(token, request.remote_ip + browser.name + browser.version)
|
82
|
+
user(decoded)
|
83
|
+
end
|
84
|
+
|
85
|
+
def user(decoded)
|
86
|
+
User.current = User.find decoded[:user_id]
|
87
|
+
end
|
37
88
|
end
|
38
89
|
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This controller handles creation and authentication of LIMS User
|
4
|
+
module Lab
|
5
|
+
class UsersController < ::ApplicationController
|
6
|
+
skip_before_action :authenticate
|
7
|
+
# create a LIMS User that will be responsible for sending lab results
|
8
|
+
def create
|
9
|
+
user_params = params.permit(:username, :password)
|
10
|
+
service.create_lims_user(username: user_params['username'], password: user_params['password'])
|
11
|
+
render json: { message: 'User successfully created' }, status: 200
|
12
|
+
end
|
13
|
+
|
14
|
+
# authenticate the lims user
|
15
|
+
def login
|
16
|
+
user_params = params.permit(:username, :password)
|
17
|
+
result = service.authenticate_user(username: user_params['username'], password: user_params['password'],
|
18
|
+
user_agent: request.user_agent, request_ip: request.remote_ip)
|
19
|
+
if result.present?
|
20
|
+
render json: result, status: 200
|
21
|
+
else
|
22
|
+
render json: { message: 'Invalid Credentials Provided' }, status: 401
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def service
|
29
|
+
Lab::UserService
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Lab
|
4
|
+
# This class is used to encode and decode the JWT token
|
5
|
+
module JsonWebTokenService
|
6
|
+
class << self
|
7
|
+
SECRET_KEY = Rails.application.secrets.secret_key_base.to_s
|
8
|
+
|
9
|
+
def encode(payload, request_ip, exp = 18.hours.from_now)
|
10
|
+
payload[:exp] = exp.to_i
|
11
|
+
JWT.encode(payload, SECRET_KEY + request_ip)
|
12
|
+
end
|
13
|
+
|
14
|
+
def decode(token, request_ip)
|
15
|
+
decoded = JWT.decode(token, SECRET_KEY + request_ip)[0]
|
16
|
+
HashWithIndifferentAccess.new decoded
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -87,6 +87,13 @@ class Lab::Lims::Api::RestApi
|
|
87
87
|
end
|
88
88
|
end
|
89
89
|
|
90
|
+
def verify_tracking_number(tracking_number)
|
91
|
+
find_lims_order(tracking_number)
|
92
|
+
rescue InvalidParameters => e
|
93
|
+
Rails.logger.error("Failed to verify tracking number #{tracking_number}: #{e.message}")
|
94
|
+
false
|
95
|
+
end
|
96
|
+
|
90
97
|
private
|
91
98
|
|
92
99
|
attr_reader :config
|
@@ -57,6 +57,29 @@ module Lab
|
|
57
57
|
end
|
58
58
|
end
|
59
59
|
|
60
|
+
def process_order(order_dto)
|
61
|
+
patient = find_patient_by_nhid(order_dto[:patient][:id])
|
62
|
+
unless patient
|
63
|
+
logger.debug("Discarding order: Non local patient ##{order_dto[:patient][:id]} on order ##{order_dto[:tracking_number]}")
|
64
|
+
order_rejected(order_dto, "Patient NPID, '#{order_dto[:patient][:id]}', didn't match any local NPIDs")
|
65
|
+
return
|
66
|
+
end
|
67
|
+
|
68
|
+
if order_dto[:tests].empty?
|
69
|
+
logger.debug("Discarding order: Missing tests on order ##{order_dto[:tracking_number]}")
|
70
|
+
order_rejected(order_dto, 'Order is missing tests')
|
71
|
+
return
|
72
|
+
end
|
73
|
+
|
74
|
+
diff = match_patient_demographics(patient, order_dto['patient'])
|
75
|
+
if diff.empty?
|
76
|
+
save_order(patient, order_dto)
|
77
|
+
order_saved(order_dto)
|
78
|
+
else
|
79
|
+
save_failed_import(order_dto, 'Demographics not matching', diff)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
60
83
|
protected
|
61
84
|
|
62
85
|
def order_saved(order_dto); end
|
@@ -182,30 +205,32 @@ module Lab
|
|
182
205
|
|
183
206
|
def update_results(order, lims_results)
|
184
207
|
logger.debug("Updating results for order ##{order[:accession_number]}: #{lims_results}")
|
185
|
-
|
208
|
+
|
186
209
|
lims_results.each do |test_name, test_results|
|
187
210
|
test = find_test(order['id'], test_name)
|
188
211
|
unless test
|
189
212
|
logger.warn("Couldn't find test, #{test_name}, in order ##{order[:id]}")
|
190
213
|
next
|
191
214
|
end
|
192
|
-
|
193
|
-
next if test.result || test_results['results'].blank?
|
194
215
|
|
216
|
+
next if test.result || test_results['results'].blank?
|
217
|
+
|
218
|
+
result_date = Time.now
|
195
219
|
measures = test_results['results'].map do |indicator, value|
|
196
220
|
measure = find_measure(order, indicator, value)
|
221
|
+
result_date = value['result_date'] || result_date
|
197
222
|
next nil unless measure
|
198
223
|
|
199
224
|
measure
|
200
225
|
end
|
201
|
-
|
226
|
+
|
202
227
|
measures = measures.compact
|
203
228
|
next if measures.empty?
|
204
229
|
|
205
230
|
creator = format_result_entered_by(test_results['result_entered_by'])
|
206
231
|
|
207
232
|
ResultsService.create_results(test.id, { provider_id: User.current.person_id,
|
208
|
-
date: Utils.parse_date(test_results['date_result_entered'],
|
233
|
+
date: Utils.parse_date(test_results['date_result_entered'], result_date),
|
209
234
|
comments: "LIMS import: Entered by: #{creator}",
|
210
235
|
measures: measures } )
|
211
236
|
end
|
@@ -10,6 +10,7 @@ module Lab
|
|
10
10
|
TEST_RESULT_CONCEPT_NAME = 'Lab test result'
|
11
11
|
TEST_RESULT_INDICATOR_CONCEPT_NAME = 'Lab test result indicator'
|
12
12
|
TEST_TYPE_CONCEPT_NAME = 'Test type'
|
13
|
+
LAB_ORDER_STATUS_CONCEPT_NAME = 'lab order status'
|
13
14
|
UNKNOWN_SPECIMEN = 'Unknown'
|
14
15
|
|
15
16
|
# Encounter
|
@@ -52,6 +52,10 @@ module Lab
|
|
52
52
|
def order_test(order_params)
|
53
53
|
Order.transaction do
|
54
54
|
encounter = find_encounter(order_params)
|
55
|
+
if order_params[:accession_number].present? && check_tracking_number(order_params[:accession_number])
|
56
|
+
raise 'Accession number already exists'
|
57
|
+
end
|
58
|
+
|
55
59
|
order = create_order(encounter, order_params)
|
56
60
|
|
57
61
|
Lab::TestsService.create_tests(order, order_params[:date], order_params[:tests])
|
@@ -99,13 +103,84 @@ module Lab
|
|
99
103
|
order.target_lab&.void(reason)
|
100
104
|
|
101
105
|
order.tests.each { |test| test.void(reason) }
|
102
|
-
|
106
|
+
order.void(reason)
|
107
|
+
end
|
103
108
|
|
104
|
-
|
109
|
+
def check_tracking_number(tracking_number)
|
110
|
+
accession_number_exists?(tracking_number) || nlims_accession_number_exists?(tracking_number)
|
111
|
+
end
|
112
|
+
|
113
|
+
def update_order_status(order_params)
|
114
|
+
# find the order
|
115
|
+
order = find_order(order_params['tracking_number'])
|
116
|
+
concept = ConceptName.find_by_name Lab::Metadata::LAB_ORDER_STATUS_CONCEPT_NAME
|
117
|
+
ActiveRecord::Base.transaction do
|
118
|
+
void_order_status(order, concept)
|
119
|
+
Observation.create!(
|
120
|
+
person_id: order.patient_id,
|
121
|
+
encounter_id: order.encounter_id,
|
122
|
+
concept_id: concept.concept_id,
|
123
|
+
order_id: order.id,
|
124
|
+
obs_datetime: order_params['status_time'] || Time.now,
|
125
|
+
value_text: order_params['status'],
|
126
|
+
creator: User.current.id
|
127
|
+
)
|
128
|
+
end
|
129
|
+
create_rejection_notification(order_params) if order_params['status'] == 'test-rejected'
|
130
|
+
end
|
131
|
+
|
132
|
+
def update_order_result(order_params)
|
133
|
+
order = find_order(order_params['tracking_number'])
|
134
|
+
order_dto = Lab::Lims::OrderSerializer.serialize_order(order)
|
135
|
+
patch_order_dto_with_lims_results!(order_dto, order_params['results'])
|
136
|
+
Lab::Lims::PullWorker.new(nil).process_order(order_dto)
|
105
137
|
end
|
106
138
|
|
107
139
|
private
|
108
140
|
|
141
|
+
def create_rejection_notification(order_params)
|
142
|
+
order = find_order order_params['tracking_number']
|
143
|
+
data = { 'type': 'LIMS',
|
144
|
+
'accession_number': order&.accession_number,
|
145
|
+
'order_date': order&.start_date,
|
146
|
+
'arv_number': find_arv_number(order.patient_id),
|
147
|
+
'patient_id': result.person_id,
|
148
|
+
'ordered_by': order&.provider&.person&.name,
|
149
|
+
'rejection_reason': order_params['comments']
|
150
|
+
}.as_json
|
151
|
+
NotificationService.new.create_notification('LIMS', data)
|
152
|
+
end
|
153
|
+
|
154
|
+
def find_arv_number(patient_id)
|
155
|
+
PatientIdentifier.joins(:type)
|
156
|
+
.merge(PatientIdentifierType.where(name: 'ARV Number'))
|
157
|
+
.where(patient_id: patient_id)
|
158
|
+
.first&.identifier
|
159
|
+
end
|
160
|
+
|
161
|
+
def find_order(tracking_number)
|
162
|
+
Lab::LabOrder.find_by_accession_number(tracking_number)
|
163
|
+
end
|
164
|
+
|
165
|
+
def patch_order_dto_with_lims_results!(order_dto, results)
|
166
|
+
order_dto.merge!(
|
167
|
+
'_id' => order_dto[:tracking_number],
|
168
|
+
'_rev' => 0,
|
169
|
+
'test_results' => results.each_with_object({}) do |result, formatted_results|
|
170
|
+
test_name, measures = result
|
171
|
+
result_date = measures.delete('result_date')
|
172
|
+
|
173
|
+
formatted_results[test_name] = {
|
174
|
+
results: measures.each_with_object({}) do |measure, processed_measures|
|
175
|
+
processed_measures[measure[0]] = { 'result_value' => measure[1] }
|
176
|
+
end,
|
177
|
+
result_date: result_date,
|
178
|
+
result_entered_by: {}
|
179
|
+
}
|
180
|
+
end
|
181
|
+
)
|
182
|
+
end
|
183
|
+
|
109
184
|
##
|
110
185
|
# Extract an encounter from the given parameters.
|
111
186
|
#
|
@@ -141,6 +216,19 @@ module Lab
|
|
141
216
|
)
|
142
217
|
end
|
143
218
|
|
219
|
+
def accession_number_exists?(accession_number)
|
220
|
+
Lab::LabOrder.where(accession_number: accession_number).exists?
|
221
|
+
end
|
222
|
+
|
223
|
+
def nlims_accession_number_exists?(accession_number)
|
224
|
+
config = YAML.load_file('config/application.yml')
|
225
|
+
return false unless config['lims_api']
|
226
|
+
|
227
|
+
# fetch from the rest api and check if it exists
|
228
|
+
lims_api = Lab::Lims::ApiFactory.create_api
|
229
|
+
lims_api.verify_tracking_number(accession_number).present?
|
230
|
+
end
|
231
|
+
|
144
232
|
##
|
145
233
|
# Attach the requesting clinician to an order
|
146
234
|
def add_requesting_clinician(order, params)
|
@@ -207,6 +295,12 @@ module Lab
|
|
207
295
|
order.reason_for_test&.delete
|
208
296
|
add_reason_for_test(order, date: order.start_date, reason_for_test_id: concept_id)
|
209
297
|
end
|
298
|
+
|
299
|
+
def void_order_status(order, concept)
|
300
|
+
Observation.where(order_id: order.id, concept_id: concept.concept_id).each do |obs|
|
301
|
+
obs.void('New Status Received from LIMS')
|
302
|
+
end
|
303
|
+
end
|
210
304
|
end
|
211
305
|
end
|
212
306
|
end
|
@@ -45,6 +45,7 @@ module Lab
|
|
45
45
|
data = { Type: result_enter_by,
|
46
46
|
'Test type': ConceptName.find_by(concept_id: result.test.value_coded)&.name,
|
47
47
|
'Accession number': order&.accession_number,
|
48
|
+
'Orde date': order&.start_date,
|
48
49
|
'ARV-Number': find_arv_number(result.person_id),
|
49
50
|
PatientID: result.person_id,
|
50
51
|
'Ordered By': order&.provider&.person&.name,
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Lab
|
4
|
+
# Service for managing LIMS users
|
5
|
+
module UserService
|
6
|
+
class << self
|
7
|
+
include BCrypt
|
8
|
+
|
9
|
+
def create_lims_user(username:, password:)
|
10
|
+
validate username: username
|
11
|
+
ActiveRecord::Base.transaction do
|
12
|
+
person = create_lims_person
|
13
|
+
create_user username: username, password: password, person: person
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def authenticate_user(username:, password:, user_agent:, request_ip:)
|
18
|
+
user = User.find_by_username username
|
19
|
+
encrypted_pass = Password.new(user.password)
|
20
|
+
if encrypted_pass == password
|
21
|
+
generate_token(user, user_agent, request_ip)
|
22
|
+
else
|
23
|
+
# throw authentication error
|
24
|
+
nil
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
##
|
31
|
+
# Validate that the username doesn't already exists
|
32
|
+
def validate(username:)
|
33
|
+
raise UnprocessableEntityError, 'Username already exists' if User.find_by_username username
|
34
|
+
end
|
35
|
+
|
36
|
+
def create_lims_person
|
37
|
+
god_user = User.first
|
38
|
+
person = Person.create!(creator: god_user.id)
|
39
|
+
PersonName.create!(given_name: 'Lims', family_name: 'User', creator: god_user.id, person: person)
|
40
|
+
person
|
41
|
+
end
|
42
|
+
|
43
|
+
def create_user(username:, password:, person:)
|
44
|
+
salt = SecureRandom.base64
|
45
|
+
user = User.create!(
|
46
|
+
username: username,
|
47
|
+
password: Password.create(password),
|
48
|
+
salt: salt,
|
49
|
+
person: person,
|
50
|
+
creator: User.first.id
|
51
|
+
)
|
52
|
+
end
|
53
|
+
|
54
|
+
def generate_token(user, user_agent, request_ip)
|
55
|
+
browser = Browser.new(user_agent)
|
56
|
+
key_supplement = request_ip + browser.name + browser.version
|
57
|
+
token = Lab::JsonWebTokenService.encode({ user_id: user.id }, key_supplement)
|
58
|
+
{ auth_token: token }
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
data/config/routes.rb
CHANGED
@@ -1,12 +1,18 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
Lab::Engine.routes.draw do
|
4
|
-
resources :orders, path: 'api/v1/lab/orders'
|
4
|
+
resources :orders, path: 'api/v1/lab/orders' do
|
5
|
+
collection do
|
6
|
+
post :order_status
|
7
|
+
post :order_result
|
8
|
+
end
|
9
|
+
end
|
5
10
|
resources :tests, path: 'api/v1/lab/tests', except: %i[update] do # ?pending=true to select tests without results?
|
6
11
|
resources :results, only: %i[index create destroy]
|
7
12
|
end
|
8
13
|
|
9
14
|
get 'api/v1/lab/labels/order', to: 'labels#print_order_label'
|
15
|
+
get 'api/v1/lab/accession_number', to: 'orders#verify_tracking_number'
|
10
16
|
|
11
17
|
# Metadata
|
12
18
|
# TODO: Move the following to namespace /concepts
|
@@ -14,4 +20,9 @@ Lab::Engine.routes.draw do
|
|
14
20
|
resources :test_result_indicators, only: %i[index], path: 'api/v1/lab/test_result_indicators'
|
15
21
|
resources :test_types, only: %i[index], path: 'api/v1/lab/test_types'
|
16
22
|
resources :reasons_for_test, only: %i[index], path: 'api/v1/lab/reasons_for_test'
|
23
|
+
resources :users, only: %i[create], path: 'api/v1/lab/users' do
|
24
|
+
collection do
|
25
|
+
post :login
|
26
|
+
end
|
27
|
+
end
|
17
28
|
end
|
data/lib/lab/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: his_emr_api_lab
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.1.
|
4
|
+
version: 1.1.30
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Elizabeth Glaser Pediatric Foundation Malawi
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-
|
11
|
+
date: 2023-06-19 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: couchrest
|
@@ -247,6 +247,7 @@ files:
|
|
247
247
|
- app/controllers/lab/test_result_indicators_controller.rb
|
248
248
|
- app/controllers/lab/test_types_controller.rb
|
249
249
|
- app/controllers/lab/tests_controller.rb
|
250
|
+
- app/controllers/lab/users_controller.rb
|
250
251
|
- app/jobs/lab/application_job.rb
|
251
252
|
- app/jobs/lab/push_order_job.rb
|
252
253
|
- app/jobs/lab/update_patient_orders_job.rb
|
@@ -267,6 +268,7 @@ files:
|
|
267
268
|
- app/services/lab/accession_number_service.rb
|
268
269
|
- app/services/lab/acknowledgement_service.rb
|
269
270
|
- app/services/lab/concepts_service.rb
|
271
|
+
- app/services/lab/json_web_token_service.rb
|
270
272
|
- app/services/lab/labelling_service/order_label.rb
|
271
273
|
- app/services/lab/lims/acknowledgement_serializer.rb
|
272
274
|
- app/services/lab/lims/acknowledgement_worker.rb
|
@@ -290,6 +292,7 @@ files:
|
|
290
292
|
- app/services/lab/orders_service.rb
|
291
293
|
- app/services/lab/results_service.rb
|
292
294
|
- app/services/lab/tests_service.rb
|
295
|
+
- app/services/lab/user_service.rb
|
293
296
|
- config/routes.rb
|
294
297
|
- db/migrate/20210126092910_create_lab_lab_accession_number_counters.rb
|
295
298
|
- db/migrate/20210310115457_create_lab_lims_order_mappings.rb
|