Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.26% |
4 / 1549 |
|
1.69% |
1 / 59 |
CRAP | |
0.00% |
0 / 1 |
VoyagerRestful | |
0.26% |
4 / 1549 |
|
1.69% |
1 / 59 |
133286.93 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
init | |
2.27% |
1 / 44 |
|
0.00% |
0 / 1 |
28.33 | |||
getConfig | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getCacheKey | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
isHoldable | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
isBorrowable | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
30 | |||
isStorageRetrievalRequestAllowed | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
30 | |||
isILLRequestAllowed | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getHoldingItemsSQL | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
processHoldingRow | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
processHoldingData | |
0.00% |
0 / 62 |
|
0.00% |
0 / 1 |
462 | |||
checkRequestIsValid | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
72 | |||
checkStorageRetrievalRequestIsValid | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
30 | |||
processMyTransactionsData | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
pickUpLocationIsValid | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
getPickUpLocations | |
0.00% |
0 / 43 |
|
0.00% |
0 / 1 |
210 | |||
getDefaultPickUpLocation | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getDefaultRequestGroup | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
requestGroupSortFunction | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
30 | |||
getRequestGroups | |
0.00% |
0 / 94 |
|
0.00% |
0 / 1 |
90 | |||
makeRequest | |
0.00% |
0 / 49 |
|
0.00% |
0 / 1 |
110 | |||
encodeXML | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
buildBasicXML | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
12 | |||
extractBlockReasons | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
getRequestBlocks | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getAccountBlocks | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
checkAccountBlocks | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
42 | |||
renewMyItems | |
0.00% |
0 / 91 |
|
0.00% |
0 / 1 |
272 | |||
checkItemRequests | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
90 | |||
makeItemRequests | |
0.00% |
0 / 56 |
|
0.00% |
0 / 1 |
156 | |||
determineHoldType | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
56 | |||
holdError | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
isRecordOnLoan | |
0.00% |
0 / 36 |
|
0.00% |
0 / 1 |
20 | |||
itemsExist | |
0.00% |
0 / 37 |
|
0.00% |
0 / 1 |
30 | |||
itemsAvailable | |
0.00% |
0 / 46 |
|
0.00% |
0 / 1 |
30 | |||
getMyHoldsSQL | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
2 | |||
processMyHoldsData | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getMyHolds | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
90 | |||
placeHold | |
0.00% |
0 / 74 |
|
0.00% |
0 / 1 |
1056 | |||
cancelHolds | |
0.00% |
0 / 32 |
|
0.00% |
0 / 1 |
56 | |||
getCancelHoldDetails | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
getRenewDetails | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getMyTransactions | |
0.00% |
0 / 59 |
|
0.00% |
0 / 1 |
306 | |||
getHoldsFromApi | |
0.00% |
0 / 56 |
|
0.00% |
0 / 1 |
132 | |||
getCallSlips | |
0.00% |
0 / 61 |
|
0.00% |
0 / 1 |
156 | |||
placeStorageRetrievalRequest | |
0.00% |
0 / 60 |
|
0.00% |
0 / 1 |
240 | |||
cancelStorageRetrievalRequests | |
0.00% |
0 / 34 |
|
0.00% |
0 / 1 |
72 | |||
getCancelStorageRetrievalRequestDetails | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
getUBRequestDetails | |
0.00% |
0 / 130 |
|
0.00% |
0 / 1 |
306 | |||
checkILLRequestIsValid | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
132 | |||
getILLPickupLibraries | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
getILLPickupLocations | |
0.00% |
0 / 46 |
|
0.00% |
0 / 1 |
42 | |||
placeILLRequest | |
0.00% |
0 / 81 |
|
0.00% |
0 / 1 |
110 | |||
getMyILLRequests | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
cancelILLRequests | |
0.00% |
0 / 35 |
|
0.00% |
0 / 1 |
90 | |||
getCancelILLRequestDetails | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
isLocalInst | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
changePassword | |
0.00% |
0 / 68 |
|
0.00% |
0 / 1 |
90 | |||
supportsMethod | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | /** |
4 | * Voyager ILS Driver |
5 | * |
6 | * PHP version 8 |
7 | * |
8 | * Copyright (C) Villanova University 2007. |
9 | * Copyright (C) The National Library of Finland 2014-2016. |
10 | * |
11 | * This program is free software; you can redistribute it and/or modify |
12 | * it under the terms of the GNU General Public License version 2, |
13 | * as published by the Free Software Foundation. |
14 | * |
15 | * This program is distributed in the hope that it will be useful, |
16 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
17 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
18 | * GNU General Public License for more details. |
19 | * |
20 | * You should have received a copy of the GNU General Public License |
21 | * along with this program; if not, write to the Free Software |
22 | * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA |
23 | * |
24 | * @category VuFind |
25 | * @package ILS_Drivers |
26 | * @author Andrew S. Nagy <vufind-tech@lists.sourceforge.net> |
27 | * @author Demian Katz <demian.katz@villanova.edu> |
28 | * @author Luke O'Sullivan <l.osullivan@swansea.ac.uk> |
29 | * @author Ere Maijala <ere.maijala@helsinki.fi> |
30 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
31 | * @link https://vufind.org/wiki/development:plugins:ils_drivers Wiki |
32 | */ |
33 | |
34 | namespace VuFind\ILS\Driver; |
35 | |
36 | use PDO; |
37 | use PDOException; |
38 | use VuFind\Date\DateException; |
39 | use VuFind\Exception\ILS as ILSException; |
40 | |
41 | use function count; |
42 | use function in_array; |
43 | use function is_callable; |
44 | |
45 | /** |
46 | * Voyager Restful ILS Driver |
47 | * |
48 | * @category VuFind |
49 | * @package ILS_Drivers |
50 | * @author Andrew S. Nagy <vufind-tech@lists.sourceforge.net> |
51 | * @author Demian Katz <demian.katz@villanova.edu> |
52 | * @author Luke O'Sullivan <l.osullivan@swansea.ac.uk> |
53 | * @author Ere Maijala <ere.maijala@helsinki.fi> |
54 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
55 | * @link https://vufind.org/wiki/development:plugins:ils_drivers Wiki |
56 | */ |
57 | class VoyagerRestful extends Voyager implements |
58 | \VuFindHttp\HttpServiceAwareInterface, |
59 | \VuFind\I18n\HasSorterInterface |
60 | { |
61 | use \VuFind\Cache\CacheTrait { |
62 | getCacheKey as protected getBaseCacheKey; |
63 | } |
64 | use \VuFindHttp\HttpServiceAwareTrait; |
65 | use \VuFind\I18n\HasSorterTrait; |
66 | |
67 | /** |
68 | * Web services host |
69 | * |
70 | * @var string |
71 | */ |
72 | protected $ws_host; |
73 | |
74 | /** |
75 | * Web services port |
76 | * |
77 | * @var string |
78 | */ |
79 | protected $ws_port; |
80 | |
81 | /** |
82 | * Web services app |
83 | * |
84 | * @var string |
85 | */ |
86 | protected $ws_app; |
87 | |
88 | /** |
89 | * Web services database key |
90 | * |
91 | * @var string |
92 | */ |
93 | protected $ws_dbKey; |
94 | |
95 | /** |
96 | * Web services patron home UB ID |
97 | * |
98 | * @var string |
99 | */ |
100 | protected $ws_patronHomeUbId; |
101 | |
102 | /** |
103 | * Legal pickup locations |
104 | * |
105 | * @var array |
106 | */ |
107 | protected $ws_pickUpLocations; |
108 | |
109 | /** |
110 | * Default pickup location |
111 | * |
112 | * @var string |
113 | */ |
114 | protected $defaultPickUpLocation; |
115 | |
116 | /** |
117 | * The maximum number of holds to check at a time (0 = no limit) |
118 | * |
119 | * @var int |
120 | */ |
121 | protected $holdCheckLimit; |
122 | |
123 | /** |
124 | * The maximum number of call slips to check at a time (0 = no limit) |
125 | * |
126 | * @var int |
127 | */ |
128 | protected $callSlipCheckLimit; |
129 | |
130 | /** |
131 | * Holds mode |
132 | * |
133 | * @var string |
134 | */ |
135 | protected $holdsMode; |
136 | |
137 | /** |
138 | * Title-level holds mode |
139 | * |
140 | * @var string |
141 | */ |
142 | protected $titleHoldsMode; |
143 | |
144 | /** |
145 | * Web Services cookies. Required for at least renewals (for JSESSIONID) as |
146 | * documented at http://www.exlibrisgroup.org/display/VoyagerOI/Renew |
147 | * |
148 | * @var \Laminas\Http\Response\Header\SetCookie[] |
149 | */ |
150 | protected $cookies = false; |
151 | |
152 | /** |
153 | * Whether recalls are enabled |
154 | * |
155 | * @var bool |
156 | */ |
157 | protected $recallsEnabled; |
158 | |
159 | /** |
160 | * Whether item holds are enabled |
161 | * |
162 | * @var bool |
163 | */ |
164 | protected $itemHoldsEnabled; |
165 | |
166 | /** |
167 | * Whether request groups are enabled |
168 | * |
169 | * @var bool |
170 | */ |
171 | protected $requestGroupsEnabled; |
172 | |
173 | /** |
174 | * Default request group |
175 | * |
176 | * @var bool|string |
177 | */ |
178 | protected $defaultRequestGroup; |
179 | |
180 | /** |
181 | * Whether pickup location must belong to the request group |
182 | * |
183 | * @var bool |
184 | */ |
185 | protected $pickupLocationsInRequestGroup; |
186 | |
187 | /** |
188 | * Whether to check that items exist when placing a hold or recall request |
189 | * |
190 | * @var bool |
191 | */ |
192 | protected $checkItemsExist; |
193 | |
194 | /** |
195 | * Whether to check that items are not available when placing a hold or recall |
196 | * request |
197 | * |
198 | * @var bool |
199 | */ |
200 | protected $checkItemsNotAvailable; |
201 | |
202 | /** |
203 | * Whether to check that the user doesn't already have the record on loan when |
204 | * placing a hold or recall request |
205 | * |
206 | * @var bool |
207 | */ |
208 | protected $checkLoans; |
209 | |
210 | /** |
211 | * Item locations excluded from item availability check. |
212 | * |
213 | * @var string |
214 | */ |
215 | protected $excludedItemLocations; |
216 | |
217 | /** |
218 | * Whether it is allowed to cancel a request for an item that is available for |
219 | * pickup |
220 | * |
221 | * @var bool |
222 | */ |
223 | protected $allowCancelingAvailableRequests; |
224 | |
225 | /** |
226 | * Constructor |
227 | * |
228 | * @param \VuFind\Date\Converter $dateConverter Date converter object |
229 | * @param string $holdsMode Holds mode setting |
230 | * @param string $titleHoldsMode Title holds mode setting |
231 | */ |
232 | public function __construct( |
233 | \VuFind\Date\Converter $dateConverter, |
234 | $holdsMode = 'disabled', |
235 | $titleHoldsMode = 'disabled' |
236 | ) { |
237 | parent::__construct($dateConverter); |
238 | $this->holdsMode = $holdsMode; |
239 | $this->titleHoldsMode = $titleHoldsMode; |
240 | } |
241 | |
242 | /** |
243 | * Initialize the driver. |
244 | * |
245 | * Validate configuration and perform all resource-intensive tasks needed to |
246 | * make the driver active. |
247 | * |
248 | * @throws ILSException |
249 | * @return void |
250 | */ |
251 | public function init() |
252 | { |
253 | parent::init(); |
254 | |
255 | // Define Voyager Restful Settings |
256 | $this->ws_host = $this->config['WebServices']['host']; |
257 | $this->ws_port = $this->config['WebServices']['port']; |
258 | $this->ws_app = $this->config['WebServices']['app']; |
259 | $this->ws_dbKey = $this->config['WebServices']['dbKey']; |
260 | $this->ws_patronHomeUbId = $this->config['WebServices']['patronHomeUbId']; |
261 | $this->ws_pickUpLocations |
262 | = $this->config['pickUpLocations'] ?? false; |
263 | $this->defaultPickUpLocation |
264 | = $this->config['Holds']['defaultPickUpLocation'] ?? ''; |
265 | if ($this->defaultPickUpLocation === 'user-selected') { |
266 | $this->defaultPickUpLocation = false; |
267 | } |
268 | $this->holdCheckLimit |
269 | = $this->config['Holds']['holdCheckLimit'] ?? '15'; |
270 | $this->callSlipCheckLimit |
271 | = $this->config['StorageRetrievalRequests']['checkLimit'] ?? '15'; |
272 | |
273 | $this->recallsEnabled |
274 | = $this->config['Holds']['enableRecalls'] ?? true; |
275 | |
276 | $this->itemHoldsEnabled |
277 | = $this->config['Holds']['enableItemHolds'] ?? true; |
278 | |
279 | $this->requestGroupsEnabled |
280 | = isset($this->config['Holds']['extraHoldFields']) |
281 | && in_array( |
282 | 'requestGroup', |
283 | explode(':', $this->config['Holds']['extraHoldFields']) |
284 | ); |
285 | $this->defaultRequestGroup |
286 | = $this->config['Holds']['defaultRequestGroup'] ?? false; |
287 | if ($this->defaultRequestGroup === 'user-selected') { |
288 | $this->defaultRequestGroup = false; |
289 | } |
290 | $this->pickupLocationsInRequestGroup |
291 | = $this->config['Holds']['pickupLocationsInRequestGroup'] ?? false; |
292 | |
293 | $this->checkItemsExist |
294 | = $this->config['Holds']['checkItemsExist'] ?? false; |
295 | $this->checkItemsNotAvailable |
296 | = $this->config['Holds']['checkItemsNotAvailable'] ?? false; |
297 | $this->checkLoans |
298 | = $this->config['Holds']['checkLoans'] ?? false; |
299 | $this->excludedItemLocations |
300 | = isset($this->config['Holds']['excludedItemLocations']) |
301 | ? str_replace(':', ',', $this->config['Holds']['excludedItemLocations']) |
302 | : ''; |
303 | $this->allowCancelingAvailableRequests |
304 | = $this->config['Holds']['allowCancelingAvailableRequests'] ?? true; |
305 | } |
306 | |
307 | /** |
308 | * Public Function which retrieves renew, hold and cancel settings from the |
309 | * driver ini file. |
310 | * |
311 | * @param string $function The name of the feature to be checked |
312 | * @param array $params Optional feature-specific parameters (array) |
313 | * |
314 | * @return array An array with key-value pairs. |
315 | * |
316 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
317 | */ |
318 | public function getConfig($function, $params = []) |
319 | { |
320 | if (isset($this->config[$function])) { |
321 | $functionConfig = $this->config[$function]; |
322 | } else { |
323 | $functionConfig = false; |
324 | } |
325 | |
326 | return $functionConfig; |
327 | } |
328 | |
329 | /** |
330 | * Add instance-specific context to a cache key suffix (to ensure that |
331 | * multiple drivers don't accidentally share values in the cache. |
332 | * |
333 | * @param string $key Cache key suffix |
334 | * |
335 | * @return string |
336 | */ |
337 | protected function getCacheKey($key = null) |
338 | { |
339 | // Override the base class formatting with Voyager-specific details |
340 | // to ensure proper caching in a MultiBackend environment. |
341 | return 'VoyagerRestful-' |
342 | . md5("{$this->ws_host}|{$this->ws_dbKey}|$key"); |
343 | } |
344 | |
345 | /** |
346 | * Support method for VuFind Hold Logic. Take an array of status strings |
347 | * and determines whether or not an item is holdable based on the |
348 | * valid_hold_statuses settings in configuration file |
349 | * |
350 | * @param array $statusArray The status codes to analyze. |
351 | * |
352 | * @return bool Whether an item is holdable |
353 | */ |
354 | protected function isHoldable($statusArray) |
355 | { |
356 | // User defined hold behaviour |
357 | $is_holdable = true; |
358 | |
359 | if (!empty($this->config['Holds']['valid_hold_statuses'])) { |
360 | $valid_hold_statuses_array |
361 | = explode(':', $this->config['Holds']['valid_hold_statuses']); |
362 | |
363 | foreach ($statusArray as $status) { |
364 | if (!in_array($status, $valid_hold_statuses_array)) { |
365 | $is_holdable = false; |
366 | } |
367 | } |
368 | } |
369 | return $is_holdable; |
370 | } |
371 | |
372 | /** |
373 | * Support method for VuFind Hold Logic. Takes an item type id |
374 | * and determines whether or not an item is borrowable based on the |
375 | * non_borrowable settings in configuration file |
376 | * |
377 | * @param string $itemTypeID The item type id to analyze. |
378 | * |
379 | * @return bool Whether an item is borrowable |
380 | */ |
381 | protected function isBorrowable($itemTypeID) |
382 | { |
383 | if (isset($this->config['Holds']['borrowable'])) { |
384 | $borrowable = explode(':', $this->config['Holds']['borrowable']); |
385 | if (!in_array($itemTypeID, $borrowable)) { |
386 | return false; |
387 | } |
388 | } |
389 | if (isset($this->config['Holds']['non_borrowable'])) { |
390 | $nonBorrowable = explode(':', $this->config['Holds']['non_borrowable']); |
391 | if (in_array($itemTypeID, $nonBorrowable)) { |
392 | return false; |
393 | } |
394 | } |
395 | |
396 | return true; |
397 | } |
398 | |
399 | /** |
400 | * Support method for VuFind Storage Retrieval Request (Call Slip) Logic. |
401 | * Take a holdings row array and determine whether or not a call slip is |
402 | * allowed based on the valid_call_slip_locations settings in configuration |
403 | * file |
404 | * |
405 | * @param array $holdingsRow The holdings row to analyze. |
406 | * |
407 | * @return bool Whether an item is requestable |
408 | */ |
409 | protected function isStorageRetrievalRequestAllowed($holdingsRow) |
410 | { |
411 | $holdingsRow = $holdingsRow['_fullRow']; |
412 | if ( |
413 | !isset($holdingsRow['TEMP_ITEM_TYPE_ID']) |
414 | || !isset($holdingsRow['ITEM_TYPE_ID']) |
415 | ) { |
416 | // Not a real item |
417 | return false; |
418 | } |
419 | |
420 | if (isset($this->config['StorageRetrievalRequests']['valid_item_types'])) { |
421 | $validTypes = explode( |
422 | ':', |
423 | $this->config['StorageRetrievalRequests']['valid_item_types'] |
424 | ); |
425 | |
426 | $type = $holdingsRow['TEMP_ITEM_TYPE_ID'] |
427 | ? $holdingsRow['TEMP_ITEM_TYPE_ID'] |
428 | : $holdingsRow['ITEM_TYPE_ID']; |
429 | return in_array($type, $validTypes); |
430 | } |
431 | return true; |
432 | } |
433 | |
434 | /** |
435 | * Support method for VuFind ILL Logic. Take a holdings row array |
436 | * and determine whether or not an ILL (UB) request is allowed. |
437 | * |
438 | * @param array $holdingsRow The holdings row to analyze. |
439 | * |
440 | * @return bool Whether an item is holdable |
441 | * |
442 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
443 | */ |
444 | protected function isILLRequestAllowed($holdingsRow) |
445 | { |
446 | return true; |
447 | } |
448 | |
449 | /** |
450 | * Protected support method for getHolding. |
451 | * |
452 | * @param array $id A Bibliographic id |
453 | * |
454 | * @return array Keyed data for use in an sql query |
455 | */ |
456 | protected function getHoldingItemsSQL($id) |
457 | { |
458 | $sqlArray = parent::getHoldingItemsSQL($id); |
459 | $sqlArray['expressions'][] = 'ITEM.ITEM_TYPE_ID'; |
460 | $sqlArray['expressions'][] = 'ITEM.TEMP_ITEM_TYPE_ID'; |
461 | |
462 | return $sqlArray; |
463 | } |
464 | |
465 | /** |
466 | * Protected support method for getHolding. |
467 | * |
468 | * @param array $sqlRow SQL Row Data |
469 | * |
470 | * @return array Keyed data |
471 | */ |
472 | protected function processHoldingRow($sqlRow) |
473 | { |
474 | $row = parent::processHoldingRow($sqlRow); |
475 | $row += ['item_id' => $sqlRow['ITEM_ID'], '_fullRow' => $sqlRow]; |
476 | return $row; |
477 | } |
478 | |
479 | /** |
480 | * Protected support method for getHolding. |
481 | * |
482 | * @param array $data Item Data |
483 | * @param string $id The BIB record id |
484 | * @param array $patron Patron Data |
485 | * |
486 | * @return array Keyed data |
487 | */ |
488 | protected function processHoldingData($data, $id, $patron = null) |
489 | { |
490 | $holding = parent::processHoldingData($data, $id, $patron); |
491 | |
492 | foreach ($holding as $i => $row) { |
493 | $is_borrowable = isset($row['_fullRow']['ITEM_TYPE_ID']) |
494 | ? $this->isBorrowable($row['_fullRow']['ITEM_TYPE_ID']) : false; |
495 | $is_holdable = $this->itemHoldsEnabled |
496 | && $this->isHoldable($row['_fullRow']['STATUS_ARRAY']); |
497 | $isStorageRetrievalRequestAllowed |
498 | = isset($this->config['StorageRetrievalRequests']) |
499 | && $this->isStorageRetrievalRequestAllowed($row); |
500 | $isILLRequestAllowed = isset($this->config['ILLRequests']) |
501 | && $this->isILLRequestAllowed($row); |
502 | // If the item cannot be borrowed or if the item is not holdable, |
503 | // set is_holdable to false |
504 | if (!$is_borrowable || !$is_holdable) { |
505 | $is_holdable = false; |
506 | } |
507 | |
508 | // Only used for driver generated hold links |
509 | $addLink = false; |
510 | $addStorageRetrievalLink = false; |
511 | $holdType = ''; |
512 | $storageRetrieval = ''; |
513 | |
514 | if ($is_holdable) { |
515 | // Hold Type - If we have patron data, we can use it to determine if |
516 | // a hold link should be shown |
517 | if ($patron && $this->holdsMode == 'driver') { |
518 | // This limit is set as the api is slow to return results |
519 | if ($i < $this->holdCheckLimit && $this->holdCheckLimit != '0') { |
520 | $holdType = $this->determineHoldType( |
521 | $patron['id'], |
522 | $row['id'], |
523 | $row['item_id'] |
524 | ); |
525 | $addLink = $holdType ? $holdType : false; |
526 | } else { |
527 | $holdType = 'auto'; |
528 | $addLink = 'check'; |
529 | } |
530 | } else { |
531 | $holdType = 'auto'; |
532 | } |
533 | } |
534 | |
535 | if ($isStorageRetrievalRequestAllowed) { |
536 | if ($patron) { |
537 | if ( |
538 | $i < $this->callSlipCheckLimit |
539 | && $this->callSlipCheckLimit != '0' |
540 | ) { |
541 | $storageRetrieval = $this->checkItemRequests( |
542 | $patron['id'], |
543 | 'callslip', |
544 | $row['id'], |
545 | $row['item_id'] |
546 | ); |
547 | $addStorageRetrievalLink = $storageRetrieval |
548 | ? true |
549 | : false; |
550 | } else { |
551 | $storageRetrieval = 'auto'; |
552 | $addStorageRetrievalLink = 'check'; |
553 | } |
554 | } else { |
555 | $storageRetrieval = 'auto'; |
556 | } |
557 | } |
558 | |
559 | $ILLRequest = ''; |
560 | $addILLRequestLink = false; |
561 | // Check only that a patron has logged in |
562 | if (null !== $patron && $isILLRequestAllowed) { |
563 | $ILLRequest = 'auto'; |
564 | $addILLRequestLink = 'check'; |
565 | } |
566 | |
567 | $holding[$i] += [ |
568 | 'is_holdable' => $is_holdable, |
569 | 'holdtype' => $holdType, |
570 | 'addLink' => $addLink, |
571 | 'level' => 'copy', |
572 | 'storageRetrievalRequest' => $storageRetrieval, |
573 | 'addStorageRetrievalRequestLink' => $addStorageRetrievalLink, |
574 | 'ILLRequest' => $ILLRequest, |
575 | 'addILLRequestLink' => $addILLRequestLink, |
576 | ]; |
577 | unset($holding[$i]['_fullRow']); |
578 | } |
579 | return $holding; |
580 | } |
581 | |
582 | /** |
583 | * Check if request is valid |
584 | * |
585 | * This is responsible for determining if an item is requestable |
586 | * |
587 | * @param string $id The Bib ID |
588 | * @param array $data An Array of item data |
589 | * @param array $patron An array of patron data |
590 | * |
591 | * @return bool True if request is valid, false if not |
592 | */ |
593 | public function checkRequestIsValid($id, $data, $patron) |
594 | { |
595 | $holdType = $data['holdtype'] ?? 'auto'; |
596 | $level = $data['level'] ?? 'copy'; |
597 | $mode = ('title' == $level) ? $this->titleHoldsMode : $this->holdsMode; |
598 | if ('driver' == $mode && 'auto' == $holdType) { |
599 | $itemID = $data['item_id'] ?? false; |
600 | $result = $this->determineHoldType($patron['id'], $id, $itemID); |
601 | if (!$result) { |
602 | return false; |
603 | } |
604 | } |
605 | |
606 | if ('title' == $level && $this->requestGroupsEnabled) { |
607 | // Verify that there are valid request groups |
608 | if (!$this->getRequestGroups($id, $patron)) { |
609 | return false; |
610 | } |
611 | } |
612 | |
613 | return true; |
614 | } |
615 | |
616 | /** |
617 | * Check if storage retrieval request is valid |
618 | * |
619 | * This is responsible for determining if an item is requestable |
620 | * |
621 | * @param string $id The Bib ID |
622 | * @param array $data An Array of item data |
623 | * @param array $patron An array of patron data |
624 | * |
625 | * @return bool True if request is valid, false if not |
626 | */ |
627 | public function checkStorageRetrievalRequestIsValid($id, $data, $patron) |
628 | { |
629 | if ( |
630 | !isset($this->config['StorageRetrievalRequests']) |
631 | || $this->checkAccountBlocks($patron['id']) |
632 | ) { |
633 | return false; |
634 | } |
635 | |
636 | $level = $data['level'] ?? 'copy'; |
637 | $itemID = ($level != 'title' && isset($data['item_id'])) |
638 | ? $data['item_id'] |
639 | : false; |
640 | return $this->checkItemRequests($patron['id'], 'callslip', $id, $itemID); |
641 | } |
642 | |
643 | /** |
644 | * Protected support method for getMyTransactions. |
645 | * |
646 | * @param array $sqlRow An array of keyed data |
647 | * @param array $patron An array of keyed patron data |
648 | * |
649 | * @return array Keyed data for display by template files |
650 | */ |
651 | protected function processMyTransactionsData($sqlRow, $patron = false) |
652 | { |
653 | $transactions = parent::processMyTransactionsData($sqlRow, $patron); |
654 | |
655 | // We'll verify renewability later in getMyTransactions |
656 | $transactions['renewable'] = true; |
657 | |
658 | return $transactions; |
659 | } |
660 | |
661 | /** |
662 | * Is the selected pickup location valid for the hold? |
663 | * |
664 | * @param string $pickUpLocation Selected pickup location |
665 | * @param array $patron Patron information returned by the patronLogin |
666 | * method. |
667 | * @param array $holdDetails Details of hold being placed |
668 | * |
669 | * @return bool |
670 | */ |
671 | protected function pickUpLocationIsValid($pickUpLocation, $patron, $holdDetails) |
672 | { |
673 | $pickUpLibs = $this->getPickUpLocations($patron, $holdDetails); |
674 | foreach ($pickUpLibs as $location) { |
675 | if ($location['locationID'] == $pickUpLocation) { |
676 | return true; |
677 | } |
678 | } |
679 | return false; |
680 | } |
681 | |
682 | /** |
683 | * Get Pick Up Locations |
684 | * |
685 | * This is responsible for gettting a list of valid library locations for |
686 | * holds / recall retrieval |
687 | * |
688 | * @param array $patron Patron information returned by the patronLogin |
689 | * method. |
690 | * @param array $holdDetails Optional array, only passed in when getting a list |
691 | * in the context of placing or editing a hold. When placing a hold, it contains |
692 | * most of the same values passed to placeHold, minus the patron data. When |
693 | * editing a hold it contains all the hold information returned by getMyHolds. |
694 | * May be used to limit the pickup options or may be ignored. The driver must |
695 | * not add new options to the return array based on this data or other areas of |
696 | * VuFind may behave incorrectly. |
697 | * |
698 | * @throws ILSException |
699 | * @return array An array of associative arrays with locationID and |
700 | * locationDisplay keys |
701 | * |
702 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
703 | */ |
704 | public function getPickUpLocations($patron = false, $holdDetails = null) |
705 | { |
706 | $pickResponse = []; |
707 | $params = []; |
708 | if ($this->ws_pickUpLocations) { |
709 | foreach ($this->ws_pickUpLocations as $code => $library) { |
710 | $pickResponse[] = [ |
711 | 'locationID' => $code, |
712 | 'locationDisplay' => $library, |
713 | ]; |
714 | } |
715 | } else { |
716 | if ( |
717 | $this->requestGroupsEnabled |
718 | && $this->pickupLocationsInRequestGroup |
719 | && !empty($holdDetails['requestGroupId']) |
720 | ) { |
721 | $sql = 'SELECT CIRC_POLICY_LOCS.LOCATION_ID as location_id, ' . |
722 | 'NVL(LOCATION.LOCATION_DISPLAY_NAME, LOCATION.LOCATION_NAME) ' . |
723 | 'as location_name from ' . |
724 | $this->dbName . ".CIRC_POLICY_LOCS, $this->dbName.LOCATION, " . |
725 | "$this->dbName.REQUEST_GROUP_LOCATION rgl " . |
726 | "where CIRC_POLICY_LOCS.PICKUP_LOCATION = 'Y' " . |
727 | 'and CIRC_POLICY_LOCS.LOCATION_ID = LOCATION.LOCATION_ID ' . |
728 | 'and rgl.GROUP_ID=:requestGroupId ' . |
729 | 'and rgl.LOCATION_ID = LOCATION.LOCATION_ID'; |
730 | $params['requestGroupId'] = $holdDetails['requestGroupId']; |
731 | } else { |
732 | $sql = 'SELECT CIRC_POLICY_LOCS.LOCATION_ID as location_id, ' . |
733 | 'NVL(LOCATION.LOCATION_DISPLAY_NAME, LOCATION.LOCATION_NAME) ' . |
734 | 'as location_name from ' . |
735 | $this->dbName . ".CIRC_POLICY_LOCS, $this->dbName.LOCATION " . |
736 | "where CIRC_POLICY_LOCS.PICKUP_LOCATION = 'Y' " . |
737 | 'and CIRC_POLICY_LOCS.LOCATION_ID = LOCATION.LOCATION_ID'; |
738 | } |
739 | |
740 | try { |
741 | $sqlStmt = $this->executeSQL($sql, $params); |
742 | } catch (PDOException $e) { |
743 | $this->throwAsIlsException($e); |
744 | } |
745 | |
746 | // Read results |
747 | while ($row = $sqlStmt->fetch(PDO::FETCH_ASSOC)) { |
748 | $pickResponse[] = [ |
749 | 'locationID' => $row['LOCATION_ID'], |
750 | 'locationDisplay' => utf8_encode($row['LOCATION_NAME']), |
751 | ]; |
752 | } |
753 | } |
754 | |
755 | // Do we need to sort pickup locations? If the setting is false, don't |
756 | // bother doing any more work. If it's not set at all, default to |
757 | // alphabetical order. |
758 | $orderSetting = $this->config['Holds']['pickUpLocationOrder'] ?? 'default'; |
759 | if (count($pickResponse) > 1 && !empty($orderSetting)) { |
760 | $locationOrder = $orderSetting === 'default' |
761 | ? [] : array_flip(explode(':', $orderSetting)); |
762 | $sortFunction = function ($a, $b) use ($locationOrder) { |
763 | $aLoc = $a['locationID']; |
764 | $bLoc = $b['locationID']; |
765 | if (isset($locationOrder[$aLoc])) { |
766 | if (isset($locationOrder[$bLoc])) { |
767 | return $locationOrder[$aLoc] - $locationOrder[$bLoc]; |
768 | } |
769 | return -1; |
770 | } |
771 | if (isset($locationOrder[$bLoc])) { |
772 | return 1; |
773 | } |
774 | return $this->getSorter()->compare( |
775 | $a['locationDisplay'], |
776 | $b['locationDisplay'] |
777 | ); |
778 | }; |
779 | usort($pickResponse, $sortFunction); |
780 | } |
781 | |
782 | return $pickResponse; |
783 | } |
784 | |
785 | /** |
786 | * Get Default Pick Up Location |
787 | * |
788 | * Returns the default pick up location set in VoyagerRestful.ini |
789 | * |
790 | * @param array $patron Patron information returned by the patronLogin |
791 | * method. |
792 | * @param array $holdDetails Optional array, only passed in when getting a list |
793 | * in the context of placing a hold; contains most of the same values passed to |
794 | * placeHold, minus the patron data. May be used to limit the pickup options |
795 | * or may be ignored. |
796 | * |
797 | * @return false|string The default pickup location for the patron or false |
798 | * if the user has to choose. |
799 | * |
800 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
801 | */ |
802 | public function getDefaultPickUpLocation($patron = false, $holdDetails = null) |
803 | { |
804 | return $this->defaultPickUpLocation; |
805 | } |
806 | |
807 | /** |
808 | * Get Default Request Group |
809 | * |
810 | * Returns the default request group set in VoyagerRestful.ini |
811 | * |
812 | * @param array $patron Patron information returned by the patronLogin |
813 | * method. |
814 | * @param array $holdDetails Optional array, only passed in when getting a list |
815 | * in the context of placing a hold; contains most of the same values passed to |
816 | * placeHold, minus the patron data. May be used to limit the request group |
817 | * options or may be ignored. |
818 | * |
819 | * @return false|string The default request group for the patron or false if |
820 | * the user has to choose. |
821 | * |
822 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
823 | */ |
824 | public function getDefaultRequestGroup($patron = false, $holdDetails = null) |
825 | { |
826 | return $this->defaultRequestGroup; |
827 | } |
828 | |
829 | /** |
830 | * Sort function for sorting request groups |
831 | * |
832 | * @param array $a Request group |
833 | * @param array $b Request group |
834 | * |
835 | * @return number |
836 | */ |
837 | protected function requestGroupSortFunction($a, $b) |
838 | { |
839 | $requestGroupOrder = isset($this->config['Holds']['requestGroupOrder']) |
840 | ? explode(':', $this->config['Holds']['requestGroupOrder']) |
841 | : []; |
842 | $requestGroupOrder = array_flip($requestGroupOrder); |
843 | if (isset($requestGroupOrder[$a['id']])) { |
844 | if (isset($requestGroupOrder[$b['id']])) { |
845 | return $requestGroupOrder[$a['id']] - $requestGroupOrder[$b['id']]; |
846 | } |
847 | return -1; |
848 | } |
849 | if (isset($requestGroupOrder[$b['id']])) { |
850 | return 1; |
851 | } |
852 | return $this->getSorter()->compare($a['name'], $b['name']); |
853 | } |
854 | |
855 | /** |
856 | * Get request groups |
857 | * |
858 | * @param int $bibId BIB ID |
859 | * @param array $patron Patron information returned by the patronLogin |
860 | * method. |
861 | * @param array $holdDetails Optional array, only passed in when getting a list |
862 | * in the context of placing a hold; contains most of the same values passed to |
863 | * placeHold, minus the patron data. May be used to limit the request group |
864 | * options or may be ignored. |
865 | * |
866 | * @return array False if request groups not in use or an array of |
867 | * associative arrays with id and name keys |
868 | * |
869 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
870 | */ |
871 | public function getRequestGroups($bibId, $patron, $holdDetails = null) |
872 | { |
873 | if (!$this->requestGroupsEnabled) { |
874 | return false; |
875 | } |
876 | |
877 | $sqlExpressions = [ |
878 | 'rg.GROUP_ID', |
879 | 'rg.GROUP_NAME', |
880 | ]; |
881 | $sqlFrom = [ |
882 | "$this->dbName.REQUEST_GROUP rg", |
883 | |
884 | ]; |
885 | $sqlWhere = []; |
886 | $sqlBind = []; |
887 | |
888 | if ($this->pickupLocationsInRequestGroup) { |
889 | // Limit to request groups that have valid pickup locations |
890 | $sqlWhere[] = <<<EOT |
891 | rg.GROUP_ID IN ( |
892 | SELECT rgl.GROUP_ID |
893 | FROM $this->dbName.REQUEST_GROUP_LOCATION rgl |
894 | WHERE rgl.LOCATION_ID IN ( |
895 | SELECT cpl.LOCATION_ID |
896 | FROM $this->dbName.CIRC_POLICY_LOCS cpl |
897 | WHERE cpl.PICKUP_LOCATION='Y' |
898 | ) |
899 | ) |
900 | EOT; |
901 | } |
902 | |
903 | if ($this->checkItemsExist) { |
904 | $sqlWhere[] = <<<EOT |
905 | rg.GROUP_ID IN ( |
906 | SELECT rgl.GROUP_ID |
907 | FROM $this->dbName.REQUEST_GROUP_LOCATION rgl |
908 | WHERE rgl.LOCATION_ID IN ( |
909 | SELECT mm.LOCATION_ID FROM $this->dbName.MFHD_MASTER mm |
910 | WHERE mm.SUPPRESS_IN_OPAC='N' |
911 | AND mm.MFHD_ID IN ( |
912 | SELECT mi.MFHD_ID |
913 | FROM $this->dbName.MFHD_ITEM mi, $this->dbName.BIB_ITEM bi |
914 | WHERE mi.ITEM_ID = bi.ITEM_ID AND bi.BIB_ID=:bibId |
915 | ) |
916 | ) |
917 | ) |
918 | EOT; |
919 | $sqlBind['bibId'] = $bibId; |
920 | } |
921 | |
922 | if ($this->checkItemsNotAvailable) { |
923 | // Build first the inner query that return item statuses for all request |
924 | // groups |
925 | $subExpressions = [ |
926 | 'sub_rgl.GROUP_ID', |
927 | 'sub_i.ITEM_ID', |
928 | 'max(sub_ist.ITEM_STATUS) as STATUS', |
929 | ]; |
930 | |
931 | $subFrom = [ |
932 | "$this->dbName.ITEM_STATUS sub_ist", |
933 | "$this->dbName.BIB_ITEM sub_bi", |
934 | "$this->dbName.ITEM sub_i", |
935 | "$this->dbName.REQUEST_GROUP_LOCATION sub_rgl", |
936 | "$this->dbName.MFHD_ITEM sub_mi", |
937 | "$this->dbName.MFHD_MASTER sub_mm", |
938 | ]; |
939 | |
940 | $subWhere = [ |
941 | 'sub_bi.BIB_ID=:subBibId', |
942 | 'sub_i.ITEM_ID=sub_bi.ITEM_ID', |
943 | 'sub_ist.ITEM_ID=sub_i.ITEM_ID', |
944 | 'sub_mi.ITEM_ID=sub_i.ITEM_ID', |
945 | 'sub_mm.MFHD_ID=sub_mi.MFHD_ID', |
946 | 'sub_rgl.LOCATION_ID=sub_mm.LOCATION_ID', |
947 | "sub_mm.SUPPRESS_IN_OPAC='N'", |
948 | ]; |
949 | |
950 | $subGroup = [ |
951 | 'sub_rgl.GROUP_ID', |
952 | 'sub_i.ITEM_ID', |
953 | ]; |
954 | |
955 | $sqlBind['subBibId'] = $bibId; |
956 | |
957 | $subArray = [ |
958 | 'expressions' => $subExpressions, |
959 | 'from' => $subFrom, |
960 | 'where' => $subWhere, |
961 | 'group' => $subGroup, |
962 | 'bind' => [], |
963 | ]; |
964 | |
965 | $subSql = $this->buildSqlFromArray($subArray); |
966 | |
967 | $itemWhere = <<<EOT |
968 | rg.GROUP_ID NOT IN ( |
969 | SELECT status.GROUP_ID |
970 | FROM ({$subSql['string']}) status |
971 | WHERE status.status=1 |
972 | ) |
973 | EOT; |
974 | |
975 | $key = 'disableAvailabilityCheckForRequestGroups'; |
976 | if (isset($this->config['Holds'][$key])) { |
977 | $disabledGroups = array_map( |
978 | function ($s) { |
979 | return preg_replace('/[^\d]*/', '', $s); |
980 | }, |
981 | explode(':', $this->config['Holds'][$key]) |
982 | ); |
983 | if ($disabledGroups) { |
984 | $itemWhere = "($itemWhere OR rg.GROUP_ID IN (" |
985 | . implode(',', $disabledGroups) . '))'; |
986 | } |
987 | } |
988 | $sqlWhere[] = $itemWhere; |
989 | } |
990 | |
991 | $sqlArray = [ |
992 | 'expressions' => $sqlExpressions, |
993 | 'from' => $sqlFrom, |
994 | 'where' => $sqlWhere, |
995 | 'bind' => $sqlBind, |
996 | ]; |
997 | |
998 | $sql = $this->buildSqlFromArray($sqlArray); |
999 | |
1000 | try { |
1001 | $sqlStmt = $this->executeSQL($sql); |
1002 | } catch (PDOException $e) { |
1003 | $this->throwAsIlsException($e); |
1004 | } |
1005 | |
1006 | $results = []; |
1007 | while ($row = $sqlStmt->fetch(PDO::FETCH_ASSOC)) { |
1008 | $results[] = [ |
1009 | 'id' => $row['GROUP_ID'], |
1010 | 'name' => utf8_encode($row['GROUP_NAME']), |
1011 | ]; |
1012 | } |
1013 | |
1014 | // Sort request groups |
1015 | usort($results, [$this, 'requestGroupSortFunction']); |
1016 | |
1017 | return $results; |
1018 | } |
1019 | |
1020 | /** |
1021 | * Make Request |
1022 | * |
1023 | * Makes a request to the Voyager Restful API |
1024 | * |
1025 | * @param array $hierarchy Array of key-value pairs to embed in the URL path of |
1026 | * the request (set value to false to inject a non-paired value). |
1027 | * @param array $params A keyed array of query data |
1028 | * @param string $mode The http request method to use (Default of GET) |
1029 | * @param string $xml An optional XML string to send to the API |
1030 | * |
1031 | * @throws ILSException |
1032 | * @return obj A Simple XML Object loaded with the xml data returned by the API |
1033 | */ |
1034 | protected function makeRequest( |
1035 | $hierarchy, |
1036 | $params = false, |
1037 | $mode = 'GET', |
1038 | $xml = false |
1039 | ) { |
1040 | $hierarchyString = []; |
1041 | // Build Url Base |
1042 | $urlParams = "http://{$this->ws_host}:{$this->ws_port}/{$this->ws_app}"; |
1043 | |
1044 | // Add Hierarchy |
1045 | foreach ($hierarchy as $key => $value) { |
1046 | $hierarchyString[] = ($value !== false) |
1047 | ? urlencode($key) . '/' . urlencode($value) : urlencode($key); |
1048 | } |
1049 | |
1050 | // Add Params |
1051 | $queryString = []; |
1052 | foreach ($params as $key => $param) { |
1053 | $queryString[] = urlencode($key) . '=' . urlencode($param); |
1054 | } |
1055 | |
1056 | // Build Hierarchy |
1057 | $urlParams .= '/' . implode('/', $hierarchyString); |
1058 | |
1059 | // Build Params |
1060 | $urlParams .= '?' . implode('&', $queryString); |
1061 | |
1062 | // Create Proxy Request |
1063 | $client = $this->httpService->createClient($urlParams); |
1064 | |
1065 | // Add any cookies |
1066 | if ($this->cookies) { |
1067 | $client->addCookie($this->cookies); |
1068 | } |
1069 | |
1070 | // Set timeout value |
1071 | $timeout = $this->config['Catalog']['http_timeout'] ?? 30; |
1072 | $client->setOptions(['timeout' => $timeout]); |
1073 | |
1074 | // Attach XML if necessary |
1075 | if ($xml !== false) { |
1076 | $client->setEncType('text/xml'); |
1077 | $client->setRawBody($xml); |
1078 | } |
1079 | |
1080 | // Send Request and Retrieve Response |
1081 | $startTime = microtime(true); |
1082 | try { |
1083 | $result = $client->setMethod($mode)->send(); |
1084 | } catch (\Exception $e) { |
1085 | $this->error( |
1086 | "$mode request for '$urlParams' with contents '$xml' failed: " |
1087 | . $e->getMessage() |
1088 | ); |
1089 | throw new ILSException('Problem with RESTful API.'); |
1090 | } |
1091 | if (!$result->isSuccess()) { |
1092 | $this->error( |
1093 | "$mode request for '$urlParams' with contents '$xml' failed: " |
1094 | . $result->getStatusCode() . ': ' . $result->getReasonPhrase() |
1095 | ); |
1096 | throw new ILSException('Problem with RESTful API.'); |
1097 | } |
1098 | |
1099 | // Store cookies |
1100 | $cookie = $result->getCookie(); |
1101 | if ($cookie) { |
1102 | $this->cookies = $cookie; |
1103 | } |
1104 | |
1105 | // Process response |
1106 | $xmlResponse = $result->getBody(); |
1107 | $this->debug( |
1108 | '[' . round(microtime(true) - $startTime, 4) . 's]' |
1109 | . " $mode request $urlParams, contents:" . PHP_EOL . $xml |
1110 | . PHP_EOL . 'response: ' . PHP_EOL |
1111 | . $xmlResponse |
1112 | ); |
1113 | $oldLibXML = libxml_use_internal_errors(); |
1114 | libxml_use_internal_errors(true); |
1115 | $simpleXML = simplexml_load_string($xmlResponse); |
1116 | libxml_use_internal_errors($oldLibXML); |
1117 | |
1118 | if ($simpleXML === false) { |
1119 | return false; |
1120 | } |
1121 | return $simpleXML; |
1122 | } |
1123 | |
1124 | /** |
1125 | * Encode a string for XML |
1126 | * |
1127 | * @param string $string String to be encoded |
1128 | * |
1129 | * @return string Encoded string |
1130 | */ |
1131 | protected function encodeXML($string) |
1132 | { |
1133 | return htmlspecialchars($string, ENT_COMPAT, 'UTF-8'); |
1134 | } |
1135 | |
1136 | /** |
1137 | * Build Basic XML |
1138 | * |
1139 | * Builds a simple xml string to send to the API |
1140 | * |
1141 | * @param array $xml A keyed array of xml node names and data |
1142 | * |
1143 | * @return string An XML string |
1144 | */ |
1145 | protected function buildBasicXML($xml) |
1146 | { |
1147 | $xmlString = ''; |
1148 | |
1149 | foreach ($xml as $root => $nodes) { |
1150 | $xmlString .= '<' . $root . '>'; |
1151 | |
1152 | foreach ($nodes as $nodeName => $nodeValue) { |
1153 | $xmlString .= '<' . $nodeName . '>'; |
1154 | $xmlString .= $this->encodeXML($nodeValue); |
1155 | // Split out any attributes |
1156 | $nodeName = strtok($nodeName, ' '); |
1157 | $xmlString .= '</' . $nodeName . '>'; |
1158 | } |
1159 | |
1160 | // Split out any attributes |
1161 | $root = strtok($root, ' '); |
1162 | $xmlString .= '</' . $root . '>'; |
1163 | } |
1164 | |
1165 | $xmlComplete = '<?xml version="1.0" encoding="UTF-8"?>' . $xmlString; |
1166 | |
1167 | return $xmlComplete; |
1168 | } |
1169 | |
1170 | /** |
1171 | * Given the appropriate portion of the blocks API response, extract a list |
1172 | * of block reasons that VuFind is not configured to ignore. |
1173 | * |
1174 | * @param \SimpleXMLElement $borrowBlocks borrowingBlock section of XML response |
1175 | * |
1176 | * @return array |
1177 | */ |
1178 | protected function extractBlockReasons($borrowBlocks) |
1179 | { |
1180 | $ignoredConfig = $this->config['Patron']['ignoredBlockCodes'] ?? ''; |
1181 | $ignored = array_map('trim', explode(',', $ignoredConfig)); |
1182 | $blockReason = []; |
1183 | foreach ($borrowBlocks as $borrowBlock) { |
1184 | if (!in_array((string)$borrowBlock->blockCode, $ignored)) { |
1185 | $blockReason[] = (string)$borrowBlock->blockReason; |
1186 | } |
1187 | } |
1188 | return $blockReason; |
1189 | } |
1190 | |
1191 | /** |
1192 | * Check whether the patron is blocked from placing requests (holds/ILL/SRR). |
1193 | * |
1194 | * @param array $patron Patron data from patronLogin(). |
1195 | * |
1196 | * @return mixed A boolean false if no blocks are in place and an array |
1197 | * of block reasons if blocks are in place |
1198 | */ |
1199 | public function getRequestBlocks($patron) |
1200 | { |
1201 | return $this->checkAccountBlocks($patron['id']); |
1202 | } |
1203 | |
1204 | /** |
1205 | * Check whether the patron has any blocks on their account. |
1206 | * |
1207 | * @param array $patron Patron data from patronLogin(). |
1208 | * |
1209 | * @return mixed A boolean false if no blocks are in place and an array |
1210 | * of block reasons if blocks are in place |
1211 | */ |
1212 | public function getAccountBlocks($patron) |
1213 | { |
1214 | return $this->checkAccountBlocks($patron['id']); |
1215 | } |
1216 | |
1217 | /** |
1218 | * Check Account Blocks |
1219 | * |
1220 | * Checks if a user has any blocks against their account which may prevent them |
1221 | * performing certain operations |
1222 | * |
1223 | * @param string $patronId A Patron ID |
1224 | * |
1225 | * @return mixed A boolean false if no blocks are in place and an array |
1226 | * of block reasons if blocks are in place |
1227 | */ |
1228 | protected function checkAccountBlocks($patronId) |
1229 | { |
1230 | $cacheId = "blocks|$patronId"; |
1231 | $blockReason = $this->getCachedData($cacheId); |
1232 | if (null === $blockReason) { |
1233 | // Build Hierarchy |
1234 | $hierarchy = [ |
1235 | 'patron' => $patronId, |
1236 | 'patronStatus' => 'blocks', |
1237 | ]; |
1238 | |
1239 | // Add Required Params |
1240 | $params = [ |
1241 | 'patron_homedb' => $this->ws_patronHomeUbId, |
1242 | 'view' => 'full', |
1243 | ]; |
1244 | |
1245 | $blocks = $this->makeRequest($hierarchy, $params); |
1246 | if ( |
1247 | $blocks |
1248 | && (string)$blocks->{'reply-text'} == 'ok' |
1249 | && isset($blocks->blocks->institution->borrowingBlock) |
1250 | ) { |
1251 | $blockReason = $this->extractBlockReasons( |
1252 | $blocks->blocks->institution->borrowingBlock |
1253 | ); |
1254 | } else { |
1255 | $blockReason = []; |
1256 | } |
1257 | $this->putCachedData($cacheId, $blockReason); |
1258 | } |
1259 | return empty($blockReason) ? false : $blockReason; |
1260 | } |
1261 | |
1262 | /** |
1263 | * Renew My Items |
1264 | * |
1265 | * Function for attempting to renew a patron's items. The data in |
1266 | * $renewDetails['details'] is determined by getRenewDetails(). |
1267 | * |
1268 | * @param array $renewDetails An array of data required for renewing items |
1269 | * including the Patron ID and an array of renewal IDS |
1270 | * |
1271 | * @return array An array of renewal information keyed by item ID |
1272 | */ |
1273 | public function renewMyItems($renewDetails) |
1274 | { |
1275 | $patron = $renewDetails['patron']; |
1276 | $finalResult = ['details' => []]; |
1277 | |
1278 | // Get Account Blocks |
1279 | $finalResult['blocks'] = $this->checkAccountBlocks($patron['id']); |
1280 | |
1281 | if (!$finalResult['blocks']) { |
1282 | // Add Items and Attempt Renewal |
1283 | $itemIdentifiers = ''; |
1284 | |
1285 | foreach ($renewDetails['details'] as $renewID) { |
1286 | [$dbKey, $loanId] = explode('|', $renewID); |
1287 | if (!$dbKey) { |
1288 | $dbKey = $this->ws_dbKey; |
1289 | } |
1290 | |
1291 | $loanId = $this->encodeXML($loanId); |
1292 | $dbKey = $this->encodeXML($dbKey); |
1293 | |
1294 | $itemIdentifiers .= <<<EOT |
1295 | <myac:itemIdentifier> |
1296 | <myac:itemId>$loanId</myac:itemId> |
1297 | <myac:ubId>$dbKey</myac:ubId> |
1298 | </myac:itemIdentifier> |
1299 | EOT; |
1300 | } |
1301 | |
1302 | $patronId = $this->encodeXML($patron['id']); |
1303 | $lastname = $this->encodeXML($patron['lastname']); |
1304 | $barcode = $this->encodeXML($patron['cat_username']); |
1305 | $localUbId = $this->encodeXML($this->ws_patronHomeUbId); |
1306 | |
1307 | // The RenewService has a weird prerequisite that |
1308 | // AuthenticatePatronService must be called first and JSESSIONID header |
1309 | // be preserved. There's no explanation why this is required, and a |
1310 | // quick check implies that RenewService works without it at least in |
1311 | // Voyager 8.1, but who knows if it fails with UB or something, so let's |
1312 | // try to play along with the rules. |
1313 | $xml = <<<EOT |
1314 | <?xml version="1.0" encoding="UTF-8"?> |
1315 | <ser:serviceParameters |
1316 | xmlns:ser="http://www.endinfosys.com/Voyager/serviceParameters"> |
1317 | <ser:patronIdentifier lastName="$lastname" patronHomeUbId="$localUbId"> |
1318 | <ser:authFactor type="B">$barcode</ser:authFactor> |
1319 | </ser:patronIdentifier> |
1320 | </ser:serviceParameters> |
1321 | EOT; |
1322 | |
1323 | $response = $this->makeRequest( |
1324 | ['AuthenticatePatronService' => false], |
1325 | [], |
1326 | 'POST', |
1327 | $xml |
1328 | ); |
1329 | if ($response === false) { |
1330 | throw new ILSException('renew_error'); |
1331 | } |
1332 | |
1333 | $xml = <<<EOT |
1334 | <?xml version="1.0" encoding="UTF-8"?> |
1335 | <ser:serviceParameters |
1336 | xmlns:ser="http://www.endinfosys.com/Voyager/serviceParameters"> |
1337 | <ser:parameters/> |
1338 | <ser:definedParameters xsi:type="myac:myAccountServiceParametersType" |
1339 | xmlns:myac="http://www.endinfosys.com/Voyager/myAccount" |
1340 | xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> |
1341 | $itemIdentifiers |
1342 | </ser:definedParameters> |
1343 | <ser:patronIdentifier lastName="$lastname" patronHomeUbId="$localUbId" |
1344 | patronId="$patronId"> |
1345 | <ser:authFactor type="B">$barcode</ser:authFactor> |
1346 | </ser:patronIdentifier> |
1347 | </ser:serviceParameters> |
1348 | EOT; |
1349 | |
1350 | $response = $this->makeRequest( |
1351 | ['RenewService' => false], |
1352 | [], |
1353 | 'POST', |
1354 | $xml |
1355 | ); |
1356 | if ($response === false) { |
1357 | throw new ILSException('renew_error'); |
1358 | } |
1359 | |
1360 | // Process |
1361 | $myac_ns = 'http://www.endinfosys.com/Voyager/myAccount'; |
1362 | $response->registerXPathNamespace( |
1363 | 'ser', |
1364 | 'http://www.endinfosys.com/Voyager/serviceParameters' |
1365 | ); |
1366 | $response->registerXPathNamespace('myac', $myac_ns); |
1367 | // The service doesn't actually return messages (in Voyager 8.1), |
1368 | // but maybe in the future... |
1369 | foreach ($response->xpath('//ser:message') as $message) { |
1370 | if ( |
1371 | $message->attributes()->type == 'system' |
1372 | || $message->attributes()->type == 'error' |
1373 | ) { |
1374 | return false; |
1375 | } |
1376 | } |
1377 | foreach ($response->xpath('//myac:clusterChargedItems') as $cluster) { |
1378 | $cluster = $cluster->children($myac_ns); |
1379 | $dbKey = (string)$cluster->cluster->ubSiteId; |
1380 | foreach ($cluster->chargedItem as $chargedItem) { |
1381 | $chargedItem = $chargedItem->children($myac_ns); |
1382 | $renewStatus = $chargedItem->renewStatus; |
1383 | if (!$renewStatus) { |
1384 | continue; |
1385 | } |
1386 | $renewed = false; |
1387 | foreach ($renewStatus->status as $status) { |
1388 | if ((string)$status == 'Renewed') { |
1389 | $renewed = true; |
1390 | } |
1391 | } |
1392 | |
1393 | $result = []; |
1394 | $result['item_id'] = (string)$chargedItem->itemId; |
1395 | $result['sysMessage'] = (string)$renewStatus->status; |
1396 | |
1397 | $dueDate = (string)$chargedItem->dueDate; |
1398 | try { |
1399 | $newDate = $this->dateFormat->convertToDisplayDate( |
1400 | 'Y-m-d H:i', |
1401 | $dueDate |
1402 | ); |
1403 | $response['new_date'] = $newDate; |
1404 | } catch (DateException $e) { |
1405 | // If we can't parse out the date, use the raw string: |
1406 | $response['new_date'] = $dueDate; |
1407 | } |
1408 | try { |
1409 | $newTime = $this->dateFormat->convertToDisplayTime( |
1410 | 'Y-m-d H:i', |
1411 | $dueDate |
1412 | ); |
1413 | $response['new_time'] = $newTime; |
1414 | } catch (DateException $e) { |
1415 | // If we can't parse out the time, just ignore it: |
1416 | $response['new_time'] = false; |
1417 | } |
1418 | $result['success'] = $renewed; |
1419 | |
1420 | $finalResult['details'][$result['item_id']] = $result; |
1421 | } |
1422 | } |
1423 | } |
1424 | return $finalResult; |
1425 | } |
1426 | |
1427 | /** |
1428 | * Check Item Requests |
1429 | * |
1430 | * Determines if a user can place a hold or recall on a specific item |
1431 | * |
1432 | * @param string $patronId The user's Patron ID |
1433 | * @param string $request The request type (hold or recall) |
1434 | * @param string $bibId An item's Bib ID |
1435 | * @param string $itemId An item's Item ID (optional) |
1436 | * |
1437 | * @return bool true if the request can be made, false if it cannot |
1438 | */ |
1439 | protected function checkItemRequests( |
1440 | $patronId, |
1441 | $request, |
1442 | $bibId, |
1443 | $itemId = false |
1444 | ) { |
1445 | if (!empty($bibId) && !empty($patronId) && !empty($request)) { |
1446 | $hierarchy = []; |
1447 | |
1448 | // Build Hierarchy |
1449 | $hierarchy['record'] = $bibId; |
1450 | |
1451 | if ($itemId) { |
1452 | $hierarchy['items'] = $itemId; |
1453 | } |
1454 | |
1455 | $hierarchy[$request] = false; |
1456 | |
1457 | // Add Required Params |
1458 | $params = [ |
1459 | 'patron' => $patronId, |
1460 | 'patron_homedb' => $this->ws_patronHomeUbId, |
1461 | 'view' => 'full', |
1462 | ]; |
1463 | |
1464 | $check = $this->makeRequest($hierarchy, $params, 'GET', false); |
1465 | |
1466 | if ($check) { |
1467 | // Process |
1468 | $check = $check->children(); |
1469 | $node = 'reply-text'; |
1470 | $reply = (string)$check->$node; |
1471 | |
1472 | // Valid Response |
1473 | if ($reply == 'ok') { |
1474 | if ($check->$request) { |
1475 | $requestAttributes = $check->$request->attributes(); |
1476 | if ($requestAttributes['allowed'] == 'Y') { |
1477 | return true; |
1478 | } |
1479 | } |
1480 | } |
1481 | } |
1482 | } |
1483 | return false; |
1484 | } |
1485 | |
1486 | /** |
1487 | * Make Item Requests |
1488 | * |
1489 | * Places a Hold or Recall for a particular title or item |
1490 | * |
1491 | * @param string $patron Patron information from patronLogin |
1492 | * @param string $type The request type (hold or recall) |
1493 | * @param array $requestData An array of parameters to submit with the request |
1494 | * |
1495 | * @return array An array of data from the attempted request |
1496 | * including success, status and a System Message (if available) |
1497 | */ |
1498 | protected function makeItemRequests( |
1499 | $patron, |
1500 | $type, |
1501 | $requestData |
1502 | ) { |
1503 | if ( |
1504 | empty($patron) || empty($requestData) || empty($requestData['bibId']) |
1505 | || empty($type) |
1506 | ) { |
1507 | return ['success' => false, 'status' => 'hold_error_fail']; |
1508 | } |
1509 | |
1510 | // Build request |
1511 | $patronId = htmlspecialchars($patron['id'], ENT_COMPAT, 'UTF-8'); |
1512 | $lastname = htmlspecialchars($patron['lastname'], ENT_COMPAT, 'UTF-8'); |
1513 | $barcode = htmlspecialchars($patron['cat_username'], ENT_COMPAT, 'UTF-8'); |
1514 | $localUbId = htmlspecialchars($this->ws_patronHomeUbId, ENT_COMPAT, 'UTF-8'); |
1515 | $type = strtoupper($type); |
1516 | $cval = 'anyCopy'; |
1517 | if (isset($requestData['itemId'])) { |
1518 | $cval = 'thisCopy'; |
1519 | } elseif (isset($requestData['requestGroupId'])) { |
1520 | $cval = 'anyCopyAt'; |
1521 | } |
1522 | |
1523 | // Build request |
1524 | $xml = <<<EOT |
1525 | <?xml version="1.0" encoding="UTF-8"?> |
1526 | <ser:serviceParameters |
1527 | xmlns:ser="http://www.endinfosys.com/Voyager/serviceParameters"> |
1528 | <ser:parameters> |
1529 | <ser:parameter key="bibDbCode"> |
1530 | <ser:value>LOCAL</ser:value> |
1531 | </ser:parameter> |
1532 | <ser:parameter key="requestCode"> |
1533 | <ser:value>$type</ser:value> |
1534 | </ser:parameter> |
1535 | <ser:parameter key="requestSiteId"> |
1536 | <ser:value>$localUbId</ser:value> |
1537 | </ser:parameter> |
1538 | <ser:parameter key="CVAL"> |
1539 | <ser:value>$cval</ser:value> |
1540 | </ser:parameter> |
1541 | |
1542 | EOT; |
1543 | foreach ($requestData as $key => $value) { |
1544 | $value = htmlspecialchars($value, ENT_COMPAT, 'UTF-8'); |
1545 | $xml .= <<<EOT |
1546 | <ser:parameter key="$key"> |
1547 | <ser:value>$value</ser:value> |
1548 | </ser:parameter> |
1549 | |
1550 | EOT; |
1551 | } |
1552 | $xml .= <<<EOT |
1553 | </ser:parameters> |
1554 | <ser:patronIdentifier lastName="$lastname" patronHomeUbId="$localUbId" |
1555 | patronId="$patronId"> |
1556 | <ser:authFactor type="B">$barcode</ser:authFactor> |
1557 | </ser:patronIdentifier> |
1558 | </ser:serviceParameters> |
1559 | EOT; |
1560 | |
1561 | $response = $this->makeRequest( |
1562 | ['SendPatronRequestService' => false], |
1563 | [], |
1564 | 'POST', |
1565 | $xml |
1566 | ); |
1567 | |
1568 | if ($response === false) { |
1569 | return $this->holdError('hold_error_system'); |
1570 | } |
1571 | // Process |
1572 | $response->registerXPathNamespace( |
1573 | 'ser', |
1574 | 'http://www.endinfosys.com/Voyager/serviceParameters' |
1575 | ); |
1576 | $response->registerXPathNamespace( |
1577 | 'req', |
1578 | 'http://www.endinfosys.com/Voyager/requests' |
1579 | ); |
1580 | foreach ($response->xpath('//ser:message') as $message) { |
1581 | if ($message->attributes()->type == 'success') { |
1582 | return [ |
1583 | 'success' => true, |
1584 | 'status' => 'hold_request_success', |
1585 | ]; |
1586 | } |
1587 | if ($message->attributes()->type == 'system') { |
1588 | return $this->holdError('hold_error_system'); |
1589 | } |
1590 | } |
1591 | |
1592 | return $this->holdError('hold_error_blocked'); |
1593 | } |
1594 | |
1595 | /** |
1596 | * Determine Hold Type |
1597 | * |
1598 | * Determines if a user can place a hold or recall on a particular item |
1599 | * |
1600 | * @param string $patronId The user's Patron ID |
1601 | * @param string $bibId An item's Bib ID |
1602 | * @param string $itemId An item's Item ID (optional) |
1603 | * |
1604 | * @return string The name of the request method to use or false on |
1605 | * failure |
1606 | */ |
1607 | protected function determineHoldType($patronId, $bibId, $itemId = false) |
1608 | { |
1609 | if ($itemId && !$this->itemHoldsEnabled) { |
1610 | return false; |
1611 | } |
1612 | |
1613 | // Check for account Blocks |
1614 | if ($this->checkAccountBlocks($patronId)) { |
1615 | return false; |
1616 | } |
1617 | |
1618 | // Check Recalls First |
1619 | if ($this->recallsEnabled) { |
1620 | $recall = $this->checkItemRequests($patronId, 'recall', $bibId, $itemId); |
1621 | if ($recall) { |
1622 | return 'recall'; |
1623 | } |
1624 | } |
1625 | // Check Holds |
1626 | $hold = $this->checkItemRequests($patronId, 'hold', $bibId, $itemId); |
1627 | if ($hold) { |
1628 | return 'hold'; |
1629 | } |
1630 | return false; |
1631 | } |
1632 | |
1633 | /** |
1634 | * Hold Error |
1635 | * |
1636 | * Returns a Hold Error Message |
1637 | * |
1638 | * @param string $msg An error message string |
1639 | * |
1640 | * @return array An array with a success (boolean) and sysMessage key |
1641 | */ |
1642 | protected function holdError($msg) |
1643 | { |
1644 | return [ |
1645 | 'success' => false, |
1646 | 'sysMessage' => $msg, |
1647 | ]; |
1648 | } |
1649 | |
1650 | /** |
1651 | * Check whether the given patron has the given bib record or its item on loan. |
1652 | * |
1653 | * @param int $patronId Patron ID |
1654 | * @param int $bibId Bib ID |
1655 | * @param int $itemId Item ID (optional) |
1656 | * |
1657 | * @return bool |
1658 | */ |
1659 | protected function isRecordOnLoan($patronId, $bibId, $itemId = null) |
1660 | { |
1661 | $sqlExpressions = [ |
1662 | 'count(cta.ITEM_ID) CNT', |
1663 | ]; |
1664 | |
1665 | $sqlFrom = [ |
1666 | "$this->dbName.BIB_ITEM bi", |
1667 | "$this->dbName.CIRC_TRANSACTIONS cta", |
1668 | ]; |
1669 | |
1670 | $sqlWhere = [ |
1671 | 'cta.PATRON_ID=:patronId', |
1672 | 'bi.BIB_ID=:bibId', |
1673 | 'bi.ITEM_ID=cta.ITEM_ID', |
1674 | ]; |
1675 | |
1676 | if ($this->requestGroupsEnabled) { |
1677 | $sqlFrom[] = "$this->dbName.REQUEST_GROUP_LOCATION rgl"; |
1678 | $sqlFrom[] = "$this->dbName.MFHD_ITEM mi"; |
1679 | $sqlFrom[] = "$this->dbName.MFHD_MASTER mm"; |
1680 | |
1681 | $sqlWhere[] = 'mi.ITEM_ID=cta.ITEM_ID'; |
1682 | $sqlWhere[] = 'mm.MFHD_ID=mi.MFHD_ID'; |
1683 | $sqlWhere[] = 'rgl.LOCATION_ID=mm.LOCATION_ID'; |
1684 | $sqlWhere[] = "mm.SUPPRESS_IN_OPAC='N'"; |
1685 | } |
1686 | |
1687 | $sqlBind = ['patronId' => $patronId, 'bibId' => $bibId]; |
1688 | |
1689 | if (null !== $itemId) { |
1690 | $sqlWhere[] = 'cta.ITEM_ID=:itemId'; |
1691 | $sqlBind['itemId'] = $itemId; |
1692 | } |
1693 | |
1694 | $sqlArray = [ |
1695 | 'expressions' => $sqlExpressions, |
1696 | 'from' => $sqlFrom, |
1697 | 'where' => $sqlWhere, |
1698 | 'bind' => $sqlBind, |
1699 | ]; |
1700 | |
1701 | $sql = $this->buildSqlFromArray($sqlArray); |
1702 | |
1703 | try { |
1704 | $sqlStmt = $this->executeSQL($sql); |
1705 | $sqlRow = $sqlStmt->fetch(PDO::FETCH_ASSOC); |
1706 | } catch (PDOException $e) { |
1707 | $this->throwAsIlsException($e); |
1708 | } |
1709 | return $sqlRow['CNT'] > 0; |
1710 | } |
1711 | |
1712 | /** |
1713 | * Check whether items exist for the given BIB ID |
1714 | * |
1715 | * @param int $bibId BIB ID |
1716 | * @param ?int $requestGroupId Request group ID or null |
1717 | * |
1718 | * @return bool |
1719 | */ |
1720 | protected function itemsExist($bibId, ?int $requestGroupId = null) |
1721 | { |
1722 | $sqlExpressions = [ |
1723 | 'count(i.ITEM_ID) CNT', |
1724 | ]; |
1725 | |
1726 | $sqlFrom = [ |
1727 | "$this->dbName.BIB_ITEM bi", |
1728 | "$this->dbName.ITEM i", |
1729 | "$this->dbName.MFHD_ITEM mi", |
1730 | "$this->dbName.MFHD_MASTER mm", |
1731 | ]; |
1732 | |
1733 | $sqlWhere = [ |
1734 | 'bi.BIB_ID=:bibId', |
1735 | 'i.ITEM_ID=bi.ITEM_ID', |
1736 | 'mi.ITEM_ID=i.ITEM_ID', |
1737 | 'mm.MFHD_ID=mi.MFHD_ID', |
1738 | "mm.SUPPRESS_IN_OPAC='N'", |
1739 | ]; |
1740 | |
1741 | if ($this->excludedItemLocations) { |
1742 | $sqlWhere[] = 'mm.LOCATION_ID not in (' . $this->excludedItemLocations . |
1743 | ')'; |
1744 | } |
1745 | |
1746 | $sqlBind = ['bibId' => $bibId]; |
1747 | |
1748 | if ($this->requestGroupsEnabled && isset($requestGroupId)) { |
1749 | $sqlFrom[] = "$this->dbName.REQUEST_GROUP_LOCATION rgl"; |
1750 | |
1751 | $sqlWhere[] = 'rgl.LOCATION_ID=mm.LOCATION_ID'; |
1752 | $sqlWhere[] = 'rgl.GROUP_ID=:requestGroupId'; |
1753 | |
1754 | $sqlBind['requestGroupId'] = $requestGroupId; |
1755 | } |
1756 | |
1757 | $sqlArray = [ |
1758 | 'expressions' => $sqlExpressions, |
1759 | 'from' => $sqlFrom, |
1760 | 'where' => $sqlWhere, |
1761 | 'bind' => $sqlBind, |
1762 | ]; |
1763 | |
1764 | $sql = $this->buildSqlFromArray($sqlArray); |
1765 | try { |
1766 | $sqlStmt = $this->executeSQL($sql); |
1767 | $sqlRow = $sqlStmt->fetch(PDO::FETCH_ASSOC); |
1768 | } catch (PDOException $e) { |
1769 | $this->throwAsIlsException($e); |
1770 | } |
1771 | return $sqlRow['CNT'] > 0; |
1772 | } |
1773 | |
1774 | /** |
1775 | * Check whether there are items available for loan for the given BIB ID |
1776 | * |
1777 | * @param int $bibId BIB ID |
1778 | * @param ?int $requestGroupId Request group ID or null |
1779 | * |
1780 | * @return bool |
1781 | */ |
1782 | protected function itemsAvailable($bibId, ?int $requestGroupId = null) |
1783 | { |
1784 | // Build inner query first |
1785 | $sqlExpressions = [ |
1786 | 'i.ITEM_ID', |
1787 | 'max(ist.ITEM_STATUS) as STATUS', |
1788 | ]; |
1789 | |
1790 | $sqlFrom = [ |
1791 | "$this->dbName.ITEM_STATUS ist", |
1792 | "$this->dbName.BIB_ITEM bi", |
1793 | "$this->dbName.ITEM i", |
1794 | "$this->dbName.MFHD_ITEM mi", |
1795 | "$this->dbName.MFHD_MASTER mm", |
1796 | ]; |
1797 | |
1798 | $sqlWhere = [ |
1799 | 'bi.BIB_ID=:bibId', |
1800 | 'i.ITEM_ID=bi.ITEM_ID', |
1801 | 'ist.ITEM_ID=i.ITEM_ID', |
1802 | 'mi.ITEM_ID=i.ITEM_ID', |
1803 | 'mm.MFHD_ID=mi.MFHD_ID', |
1804 | "mm.SUPPRESS_IN_OPAC='N'", |
1805 | ]; |
1806 | |
1807 | if ($this->excludedItemLocations) { |
1808 | $sqlWhere[] = 'mm.LOCATION_ID not in (' . $this->excludedItemLocations . |
1809 | ')'; |
1810 | } |
1811 | |
1812 | $sqlGroup = [ |
1813 | 'i.ITEM_ID', |
1814 | ]; |
1815 | |
1816 | $sqlBind = ['bibId' => $bibId]; |
1817 | |
1818 | if ($this->requestGroupsEnabled && isset($requestGroupId)) { |
1819 | $sqlFrom[] = "$this->dbName.REQUEST_GROUP_LOCATION rgl"; |
1820 | |
1821 | $sqlWhere[] = 'rgl.LOCATION_ID=mm.LOCATION_ID'; |
1822 | $sqlWhere[] = 'rgl.GROUP_ID=:requestGroupId'; |
1823 | |
1824 | $sqlBind['requestGroupId'] = $requestGroupId; |
1825 | } |
1826 | |
1827 | $sqlArray = [ |
1828 | 'expressions' => $sqlExpressions, |
1829 | 'from' => $sqlFrom, |
1830 | 'where' => $sqlWhere, |
1831 | 'group' => $sqlGroup, |
1832 | 'bind' => $sqlBind, |
1833 | ]; |
1834 | |
1835 | $sql = $this->buildSqlFromArray($sqlArray); |
1836 | $outersql = "select count(avail.item_id) CNT from ({$sql['string']}) avail" . |
1837 | ' where avail.STATUS=1'; // 1 = not charged |
1838 | |
1839 | try { |
1840 | $sqlStmt = $this->executeSQL($outersql, $sql['bind']); |
1841 | $sqlRow = $sqlStmt->fetch(PDO::FETCH_ASSOC); |
1842 | } catch (PDOException $e) { |
1843 | $this->throwAsIlsException($e); |
1844 | } |
1845 | return $sqlRow['CNT'] > 0; |
1846 | } |
1847 | |
1848 | /** |
1849 | * Protected support method for getMyHolds. |
1850 | * |
1851 | * Fetch both local and remote holds. Remote hold data will be augmented using |
1852 | * the API. |
1853 | * |
1854 | * @param array $patron Patron data for use in an sql query |
1855 | * |
1856 | * @return array Keyed data for use in an sql query |
1857 | */ |
1858 | protected function getMyHoldsSQL($patron) |
1859 | { |
1860 | // Most of our SQL settings will be identical to the parent class.... |
1861 | $sqlArray = parent::getMyHoldsSQL($patron); |
1862 | |
1863 | // Add remote holds; MFHD_ITEM and BIB_TEXT entries will be bogus for these, |
1864 | // but we'll deal with them later in getMyHolds() |
1865 | $sqlArray['expressions'][] |
1866 | = "NVL(VOYAGER_DATABASES.DB_CODE, 'LOCAL') as DB_CODE"; |
1867 | |
1868 | // We need to significantly change the where clauses to account for remote |
1869 | // holds |
1870 | $sqlArray['where'] = [ |
1871 | 'HOLD_RECALL.PATRON_ID = :id', |
1872 | 'HOLD_RECALL.HOLD_RECALL_ID = HOLD_RECALL_ITEMS.HOLD_RECALL_ID(+)', |
1873 | 'HOLD_RECALL_ITEMS.ITEM_ID = MFHD_ITEM.ITEM_ID(+)', |
1874 | '(HOLD_RECALL_ITEMS.HOLD_RECALL_STATUS IS NULL OR ' . |
1875 | 'HOLD_RECALL_ITEMS.HOLD_RECALL_STATUS < 3)', |
1876 | 'HOLD_RECALL.BIB_ID = BIB_TEXT.BIB_ID(+)', |
1877 | 'HOLD_RECALL.REQUEST_GROUP_ID = REQUEST_GROUP.GROUP_ID(+)', |
1878 | 'HOLD_RECALL.HOLDING_DB_ID = VOYAGER_DATABASES.DB_ID(+)', |
1879 | ]; |
1880 | |
1881 | return $sqlArray; |
1882 | } |
1883 | |
1884 | /** |
1885 | * Protected support method for getMyHolds. |
1886 | * |
1887 | * @param array $sqlRow An array of keyed data |
1888 | * |
1889 | * @throws DateException |
1890 | * @return array Keyed data for display by template files |
1891 | */ |
1892 | protected function processMyHoldsData($sqlRow) |
1893 | { |
1894 | $result = parent::processMyHoldsData($sqlRow); |
1895 | $result['db_code'] = $sqlRow['DB_CODE']; |
1896 | return $result; |
1897 | } |
1898 | |
1899 | /** |
1900 | * Get Patron Holds |
1901 | * |
1902 | * This is responsible for retrieving all holds by a specific patron. |
1903 | * |
1904 | * @param array $patron The patron array from patronLogin |
1905 | * |
1906 | * @throws DateException |
1907 | * @throws ILSException |
1908 | * @return array Array of the patron's holds on success. |
1909 | */ |
1910 | public function getMyHolds($patron) |
1911 | { |
1912 | $holds = parent::getMyHolds($patron); |
1913 | // Check if we have remote holds and augment if necessary |
1914 | $augment = false; |
1915 | foreach ($holds as $hold) { |
1916 | if ($hold['db_code'] != 'LOCAL') { |
1917 | $augment = true; |
1918 | break; |
1919 | } |
1920 | } |
1921 | if ($augment) { |
1922 | // Fetch hold information via the API so that we can include correct |
1923 | // title etc. for remote holds. |
1924 | $copyFields = [ |
1925 | 'id', 'item_id', 'volume', 'publication_year', 'title', |
1926 | 'institution_id', 'institution_name', |
1927 | 'institution_dbkey', 'in_transit', |
1928 | ]; |
1929 | $apiHolds = $this->getHoldsFromApi($patron, true); |
1930 | foreach ($apiHolds as $apiHold) { |
1931 | // Find the hold and add information to it |
1932 | foreach ($holds as &$hold) { |
1933 | if ($hold['reqnum'] == $apiHold['reqnum']) { |
1934 | // Ignore local holds |
1935 | if ($hold['db_code'] == 'LOCAL') { |
1936 | continue 2; |
1937 | } |
1938 | foreach ($copyFields as $field) { |
1939 | $hold[$field] = $apiHold[$field] ?? ''; |
1940 | } |
1941 | break; |
1942 | } |
1943 | } |
1944 | } |
1945 | } |
1946 | return $holds; |
1947 | } |
1948 | |
1949 | /** |
1950 | * Place Hold |
1951 | * |
1952 | * Attempts to place a hold or recall on a particular item and returns |
1953 | * an array with result details or throws an exception on failure of support |
1954 | * classes |
1955 | * |
1956 | * @param array $holdDetails An array of item and patron data |
1957 | * |
1958 | * @throws ILSException |
1959 | * @return mixed An array of data on the request including |
1960 | * whether or not it was successful and a system message (if available) |
1961 | */ |
1962 | public function placeHold($holdDetails) |
1963 | { |
1964 | $patron = $holdDetails['patron']; |
1965 | $type = isset($holdDetails['holdtype']) && !empty($holdDetails['holdtype']) |
1966 | ? $holdDetails['holdtype'] : 'auto'; |
1967 | $level = isset($holdDetails['level']) && !empty($holdDetails['level']) |
1968 | ? $holdDetails['level'] : 'copy'; |
1969 | $pickUpLocation = !empty($holdDetails['pickUpLocation']) |
1970 | ? $holdDetails['pickUpLocation'] : $this->defaultPickUpLocation; |
1971 | $itemId = $holdDetails['item_id'] ?? false; |
1972 | $comment = $holdDetails['comment'] ?? ''; |
1973 | $bibId = $holdDetails['id']; |
1974 | |
1975 | // Request was initiated before patron was logged in - |
1976 | // Let's determine Hold Type now |
1977 | if ($type == 'auto') { |
1978 | $type = $this->determineHoldType($patron['id'], $bibId, $itemId); |
1979 | if (!$type) { |
1980 | return $this->holdError('hold_error_blocked'); |
1981 | } |
1982 | } |
1983 | |
1984 | // Convert last interest date from Display Format to Voyager required format |
1985 | try { |
1986 | $lastInterestDate = $this->dateFormat->convertFromDisplayDate( |
1987 | 'Y-m-d', |
1988 | $holdDetails['requiredBy'] |
1989 | ); |
1990 | } catch (DateException $e) { |
1991 | // Hold Date is invalid |
1992 | return $this->holdError('hold_date_invalid'); |
1993 | } |
1994 | |
1995 | try { |
1996 | $checkTime = $this->dateFormat->convertFromDisplayDate( |
1997 | 'U', |
1998 | $holdDetails['requiredBy'] |
1999 | ); |
2000 | if (!is_numeric($checkTime)) { |
2001 | throw new DateException('Result should be numeric'); |
2002 | } |
2003 | } catch (DateException $e) { |
2004 | $this->throwAsIlsException($e, 'Problem parsing required by date.'); |
2005 | } |
2006 | |
2007 | if (time() > $checkTime) { |
2008 | // Hold Date is in the past |
2009 | return $this->holdError('hold_date_past'); |
2010 | } |
2011 | |
2012 | // Make Sure Pick Up Library is Valid |
2013 | if (!$this->pickUpLocationIsValid($pickUpLocation, $patron, $holdDetails)) { |
2014 | return $this->holdError('hold_invalid_pickup'); |
2015 | } |
2016 | |
2017 | if ( |
2018 | $this->requestGroupsEnabled && !$itemId |
2019 | && empty($holdDetails['requestGroupId']) |
2020 | ) { |
2021 | return $this->holdError('hold_invalid_request_group'); |
2022 | } |
2023 | |
2024 | // Optional check that the bib has items |
2025 | if ($this->checkItemsExist) { |
2026 | $exist = $this->itemsExist( |
2027 | $bibId, |
2028 | $holdDetails['requestGroupId'] ?? null |
2029 | ); |
2030 | if (!$exist) { |
2031 | return $this->holdError('hold_no_items'); |
2032 | } |
2033 | } |
2034 | |
2035 | // Optional check that the bib has no available items |
2036 | if ($this->checkItemsNotAvailable) { |
2037 | $disabledGroups = []; |
2038 | $key = 'disableAvailabilityCheckForRequestGroups'; |
2039 | if (isset($this->config['Holds'][$key])) { |
2040 | $disabledGroups = explode(':', $this->config['Holds'][$key]); |
2041 | } |
2042 | if ( |
2043 | !isset($holdDetails['requestGroupId']) |
2044 | || !in_array($holdDetails['requestGroupId'], $disabledGroups) |
2045 | ) { |
2046 | $available = $this->itemsAvailable( |
2047 | $bibId, |
2048 | $holdDetails['requestGroupId'] ?? null |
2049 | ); |
2050 | if ($available) { |
2051 | return $this->holdError('hold_items_available'); |
2052 | } |
2053 | } |
2054 | } |
2055 | |
2056 | // Optional check that the patron doesn't already have the bib on loan |
2057 | if ($this->checkLoans) { |
2058 | $checkItemId = $this->checkLoans === 'same-item' && $level == 'copy' |
2059 | && $itemId ? $itemId : null; |
2060 | if ($this->isRecordOnLoan($patron['id'], $bibId, $checkItemId)) { |
2061 | return $this->holdError('hold_record_already_on_loan'); |
2062 | } |
2063 | } |
2064 | |
2065 | // Build Request Data |
2066 | $requestData = [ |
2067 | 'bibId' => $bibId, |
2068 | 'PICK' => $pickUpLocation, |
2069 | 'REQNNA' => $lastInterestDate, |
2070 | 'REQCOMMENTS' => $comment, |
2071 | ]; |
2072 | if ($level == 'copy' && $itemId) { |
2073 | $requestData['itemId'] = $itemId; |
2074 | } elseif (isset($holdDetails['requestGroupId'])) { |
2075 | $requestData['requestGroupId'] = $holdDetails['requestGroupId']; |
2076 | } |
2077 | |
2078 | // Attempt Request |
2079 | $result = $this->makeItemRequests($patron, $type, $requestData); |
2080 | if ($result) { |
2081 | return $result; |
2082 | } |
2083 | |
2084 | return $this->holdError('hold_error_blocked'); |
2085 | } |
2086 | |
2087 | /** |
2088 | * Cancel Holds |
2089 | * |
2090 | * Attempts to Cancel a hold or recall on a particular item. The |
2091 | * data in $cancelDetails['details'] is determined by getCancelHoldDetails(). |
2092 | * |
2093 | * @param array $cancelDetails An array of item and patron data |
2094 | * |
2095 | * @return array An array of data on each request including |
2096 | * whether or not it was successful and a system message (if available) |
2097 | */ |
2098 | public function cancelHolds($cancelDetails) |
2099 | { |
2100 | $details = $cancelDetails['details']; |
2101 | $patron = $cancelDetails['patron']; |
2102 | $count = 0; |
2103 | $response = []; |
2104 | |
2105 | foreach ($details as $cancelDetails) { |
2106 | [$itemId, $cancelCode] = explode('|', $cancelDetails); |
2107 | |
2108 | // Create Rest API Cancel Key |
2109 | $cancelID = $this->ws_dbKey . '|' . $cancelCode; |
2110 | |
2111 | // Build Hierarchy |
2112 | $hierarchy = [ |
2113 | 'patron' => $patron['id'], |
2114 | 'circulationActions' => 'requests', |
2115 | 'holds' => $cancelID, |
2116 | ]; |
2117 | |
2118 | // Add Required Params |
2119 | $params = [ |
2120 | 'patron_homedb' => $this->ws_patronHomeUbId, |
2121 | 'view' => 'full', |
2122 | ]; |
2123 | |
2124 | // Get Data |
2125 | $cancel = $this->makeRequest($hierarchy, $params, 'DELETE'); |
2126 | |
2127 | if ($cancel) { |
2128 | // Process Cancel |
2129 | $cancel = $cancel->children(); |
2130 | $node = 'reply-text'; |
2131 | $reply = (string)$cancel->$node; |
2132 | $count = ($reply == 'ok') ? $count + 1 : $count; |
2133 | |
2134 | $response[$itemId] = [ |
2135 | 'success' => ($reply == 'ok') ? true : false, |
2136 | 'status' => ($reply == 'ok') |
2137 | ? 'hold_cancel_success' : 'hold_cancel_fail', |
2138 | 'sysMessage' => ($reply == 'ok') ? false : $reply, |
2139 | ]; |
2140 | } else { |
2141 | $response[$itemId] = [ |
2142 | 'success' => false, 'status' => 'hold_cancel_fail', |
2143 | ]; |
2144 | } |
2145 | } |
2146 | $result = ['count' => $count, 'items' => $response]; |
2147 | return $result; |
2148 | } |
2149 | |
2150 | /** |
2151 | * Get Cancel Hold Details |
2152 | * |
2153 | * In order to cancel a hold, Voyager requires the patron details an item ID |
2154 | * and a recall ID. This function returns the item id and recall id as a string |
2155 | * separated by a pipe, which is then submitted as form data in Hold.php. This |
2156 | * value is then extracted by the CancelHolds function. |
2157 | * |
2158 | * @param array $holdDetails A single hold array from getMyHolds |
2159 | * @param array $patron Patron information from patronLogin |
2160 | * |
2161 | * @return string Data for use in a form field |
2162 | * |
2163 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
2164 | */ |
2165 | public function getCancelHoldDetails($holdDetails, $patron = []) |
2166 | { |
2167 | if (!$this->allowCancelingAvailableRequests && $holdDetails['available']) { |
2168 | return ''; |
2169 | } |
2170 | return $holdDetails['item_id'] . '|' . $holdDetails['reqnum']; |
2171 | } |
2172 | |
2173 | /** |
2174 | * Get Renew Details |
2175 | * |
2176 | * In order to renew an item, Voyager requires the patron details and an item |
2177 | * id. This function returns the item id as a string which is then used |
2178 | * as submitted form data in checkedOut.php. This value is then extracted by |
2179 | * the RenewMyItems function. |
2180 | * |
2181 | * @param array $checkOutDetails An array of item data |
2182 | * |
2183 | * @return string Data for use in a form field |
2184 | */ |
2185 | public function getRenewDetails($checkOutDetails) |
2186 | { |
2187 | $renewDetails = ($checkOutDetails['institution_dbkey'] ?? '') |
2188 | . '|' . $checkOutDetails['item_id']; |
2189 | return $renewDetails; |
2190 | } |
2191 | |
2192 | /** |
2193 | * Get Patron Transactions |
2194 | * |
2195 | * This is responsible for retrieving all transactions (i.e. checked out items) |
2196 | * by a specific patron. |
2197 | * |
2198 | * @param array $patron The patron array from patronLogin |
2199 | * |
2200 | * @throws ILSException |
2201 | * @return mixed Array of the patron's transactions on success. |
2202 | */ |
2203 | public function getMyTransactions($patron) |
2204 | { |
2205 | // Get local loans from the database so that we can get more details |
2206 | // than available via the API. |
2207 | $transactions = parent::getMyTransactions($patron); |
2208 | |
2209 | // Get remote loans and renewability for local loans via the API |
2210 | |
2211 | // Build Hierarchy |
2212 | $hierarchy = [ |
2213 | 'patron' => $patron['id'], |
2214 | 'circulationActions' => 'loans', |
2215 | ]; |
2216 | |
2217 | // Add Required Params |
2218 | $params = [ |
2219 | 'patron_homedb' => $this->ws_patronHomeUbId, |
2220 | 'view' => 'full', |
2221 | ]; |
2222 | |
2223 | $results = $this->makeRequest($hierarchy, $params); |
2224 | |
2225 | if ($results === false) { |
2226 | throw new ILSException('System error fetching loans'); |
2227 | } |
2228 | |
2229 | $replyCode = (string)$results->{'reply-code'}; |
2230 | if ($replyCode != 0 && $replyCode != 8) { |
2231 | throw new ILSException('System error fetching loans'); |
2232 | } |
2233 | if (isset($results->loans->institution)) { |
2234 | foreach ($results->loans->institution as $institution) { |
2235 | foreach ($institution->loan as $loan) { |
2236 | if ($this->isLocalInst((string)$institution->attributes()->id)) { |
2237 | // Take only renewability for local loans, other information |
2238 | // we have already |
2239 | $renewable = (string)$loan->attributes()->canRenew == 'Y'; |
2240 | |
2241 | foreach ($transactions as &$transaction) { |
2242 | if ( |
2243 | !isset($transaction['institution_id']) |
2244 | && $transaction['item_id'] == (string)$loan->itemId |
2245 | ) { |
2246 | $transaction['renewable'] = $renewable; |
2247 | break; |
2248 | } |
2249 | } |
2250 | continue; |
2251 | } |
2252 | |
2253 | $dueStatus = false; |
2254 | $now = time(); |
2255 | $dueTimeStamp = strtotime((string)$loan->dueDate); |
2256 | if ($dueTimeStamp !== false && is_numeric($dueTimeStamp)) { |
2257 | if ($now > $dueTimeStamp) { |
2258 | $dueStatus = 'overdue'; |
2259 | } elseif ($now > $dueTimeStamp - (1 * 24 * 60 * 60)) { |
2260 | $dueStatus = 'due'; |
2261 | } |
2262 | } |
2263 | |
2264 | try { |
2265 | $dueDate = $this->dateFormat->convertToDisplayDate( |
2266 | 'Y-m-d H:i', |
2267 | (string)$loan->dueDate |
2268 | ); |
2269 | } catch (DateException $e) { |
2270 | // If we can't parse out the date, use the raw string: |
2271 | $dueDate = (string)$loan->dueDate; |
2272 | } |
2273 | |
2274 | try { |
2275 | $dueTime = $this->dateFormat->convertToDisplayTime( |
2276 | 'Y-m-d H:i', |
2277 | (string)$loan->dueDate |
2278 | ); |
2279 | } catch (DateException $e) { |
2280 | // If we can't parse out the time, just ignore it: |
2281 | $dueTime = false; |
2282 | } |
2283 | |
2284 | $transactions[] = [ |
2285 | // This is bogus, but we need something.. |
2286 | 'id' => (string)$institution->attributes()->id . '_' . |
2287 | (string)$loan->itemId, |
2288 | 'item_id' => (string)$loan->itemId, |
2289 | 'duedate' => $dueDate, |
2290 | 'dueTime' => $dueTime, |
2291 | 'dueStatus' => $dueStatus, |
2292 | 'title' => (string)$loan->title, |
2293 | 'renewable' => (string)$loan->attributes()->canRenew == 'Y', |
2294 | 'institution_id' => (string)$institution->attributes()->id, |
2295 | 'institution_name' => (string)$loan->dbName, |
2296 | 'institution_dbkey' => (string)$loan->dbKey, |
2297 | ]; |
2298 | } |
2299 | } |
2300 | } |
2301 | return $transactions; |
2302 | } |
2303 | |
2304 | /** |
2305 | * Get patron's local or remote holds from the API |
2306 | * |
2307 | * This is responsible for retrieving all local or remote holds by a specific |
2308 | * patron. |
2309 | * |
2310 | * @param array $patron The patron array from patronLogin |
2311 | * @param bool $local Whether to fetch local holds instead of remote holds |
2312 | * |
2313 | * @throws DateException |
2314 | * @throws ILSException |
2315 | * @return array Array of the patron's holds on success. |
2316 | */ |
2317 | protected function getHoldsFromApi($patron, $local) |
2318 | { |
2319 | // Build Hierarchy |
2320 | $hierarchy = [ |
2321 | 'patron' => $patron['id'], |
2322 | 'circulationActions' => 'requests', |
2323 | 'holds' => false, |
2324 | ]; |
2325 | |
2326 | // Add Required Params |
2327 | $params = [ |
2328 | 'patron_homedb' => $this->ws_patronHomeUbId, |
2329 | 'view' => 'full', |
2330 | ]; |
2331 | |
2332 | $results = $this->makeRequest($hierarchy, $params); |
2333 | |
2334 | if ($results === false) { |
2335 | throw new ILSException('System error fetching remote holds'); |
2336 | } |
2337 | |
2338 | $replyCode = (string)$results->{'reply-code'}; |
2339 | if ($replyCode != 0 && $replyCode != 8) { |
2340 | throw new ILSException('System error fetching remote holds'); |
2341 | } |
2342 | $holds = []; |
2343 | if (isset($results->holds->institution)) { |
2344 | foreach ($results->holds->institution as $institution) { |
2345 | // Filter by the $local parameter |
2346 | $isLocal = $this->isLocalInst( |
2347 | (string)$institution->attributes()->id |
2348 | ); |
2349 | if ($local != $isLocal) { |
2350 | continue; |
2351 | } |
2352 | |
2353 | foreach ($institution->hold as $hold) { |
2354 | $item = $hold->requestItem; |
2355 | |
2356 | $holds[] = [ |
2357 | 'id' => '', |
2358 | 'type' => (string)$item->holdType, |
2359 | 'location' => (string)$item->pickupLocation, |
2360 | 'expire' => (string)$item->expiredDate |
2361 | ? $this->dateFormat->convertToDisplayDate( |
2362 | 'Y-m-d', |
2363 | (string)$item->expiredDate |
2364 | ) |
2365 | : '', |
2366 | // Looks like expired date shows creation date for |
2367 | // UB requests, but who knows |
2368 | 'create' => (string)$item->expiredDate |
2369 | ? $this->dateFormat->convertToDisplayDate( |
2370 | 'Y-m-d', |
2371 | (string)$item->expiredDate |
2372 | ) |
2373 | : '', |
2374 | 'position' => (string)$item->queuePosition, |
2375 | 'available' => (string)$item->status == '2', |
2376 | 'reqnum' => (string)$item->holdRecallId, |
2377 | 'item_id' => (string)$item->itemId, |
2378 | 'volume' => '', |
2379 | 'publication_year' => '', |
2380 | 'title' => (string)$item->itemTitle, |
2381 | 'institution_id' => (string)$institution->attributes()->id, |
2382 | 'institution_name' => (string)$item->dbName, |
2383 | 'institution_dbkey' => (string)$item->dbKey, |
2384 | 'in_transit' => str_starts_with((string)$item->statusText, 'In transit to') |
2385 | ? substr((string)$item->statusText, 14) |
2386 | : '', |
2387 | ]; |
2388 | } |
2389 | } |
2390 | } |
2391 | return $holds; |
2392 | } |
2393 | |
2394 | /** |
2395 | * Get Patron Storage Retrieval Requests (Call Slips). Gets callslips via |
2396 | * the API. Returns only remote slips by default since more complete data |
2397 | * can be retrieved directly from the local database; however, the $local |
2398 | * parameter exists to support potential local customizations. |
2399 | * |
2400 | * @param array $patron The patron array from patronLogin |
2401 | * @param bool $local Whether to include local callslips |
2402 | * |
2403 | * @return mixed Array of the patron's storage retrieval requests. |
2404 | */ |
2405 | protected function getCallSlips($patron, $local = false) |
2406 | { |
2407 | // Build Hierarchy |
2408 | $hierarchy = [ |
2409 | 'patron' => $patron['id'], |
2410 | 'circulationActions' => 'requests', |
2411 | 'callslips' => false, |
2412 | ]; |
2413 | |
2414 | // Add Required Params |
2415 | $params = [ |
2416 | 'patron_homedb' => $this->ws_patronHomeUbId, |
2417 | 'view' => 'full', |
2418 | ]; |
2419 | |
2420 | $results = $this->makeRequest($hierarchy, $params); |
2421 | |
2422 | $replyCode = (string)$results->{'reply-code'}; |
2423 | if ($replyCode != 0 && $replyCode != 8) { |
2424 | throw new \Exception('System error fetching call slips'); |
2425 | } |
2426 | $requests = []; |
2427 | if (isset($results->callslips->institution)) { |
2428 | foreach ($results->callslips->institution as $institution) { |
2429 | if ( |
2430 | !$local |
2431 | && $this->isLocalInst((string)$institution->attributes()->id) |
2432 | ) { |
2433 | // Unless $local is set, ignore local callslips; we have them |
2434 | // already.... |
2435 | continue; |
2436 | } |
2437 | foreach ($institution->callslip as $callslip) { |
2438 | $item = $callslip->requestItem; |
2439 | $requests[] = [ |
2440 | 'id' => '', |
2441 | 'type' => (string)$item->holdType, |
2442 | 'location' => (string)$item->pickupLocation, |
2443 | 'expire' => (string)$item->expiredDate |
2444 | ? $this->dateFormat->convertToDisplayDate( |
2445 | 'Y-m-d', |
2446 | (string)$item->expiredDate |
2447 | ) |
2448 | : '', |
2449 | // Looks like expired date shows creation date for |
2450 | // call slip requests, but who knows |
2451 | 'create' => (string)$item->expiredDate |
2452 | ? $this->dateFormat->convertToDisplayDate( |
2453 | 'Y-m-d', |
2454 | (string)$item->expiredDate |
2455 | ) |
2456 | : '', |
2457 | 'position' => (string)$item->queuePosition, |
2458 | 'available' => (string)$item->status == '4', |
2459 | 'reqnum' => (string)$item->holdRecallId, |
2460 | 'item_id' => (string)$item->itemId, |
2461 | 'volume' => '', |
2462 | 'publication_year' => '', |
2463 | 'title' => (string)$item->itemTitle, |
2464 | 'institution_id' => (string)$institution->attributes()->id, |
2465 | 'institution_name' => (string)$item->dbName, |
2466 | 'institution_dbkey' => (string)$item->dbKey, |
2467 | 'processed' => str_starts_with((string)$item->statusText, 'Filled') |
2468 | ? $this->dateFormat->convertToDisplayDate( |
2469 | 'Y-m-d', |
2470 | substr((string)$item->statusText, 7) |
2471 | ) |
2472 | : '', |
2473 | 'canceled' => str_starts_with((string)$item->statusText, 'Canceled') |
2474 | ? $this->dateFormat->convertToDisplayDate( |
2475 | 'Y-m-d', |
2476 | substr((string)$item->statusText, 9) |
2477 | ) |
2478 | : '', |
2479 | ]; |
2480 | } |
2481 | } |
2482 | } |
2483 | return $requests; |
2484 | } |
2485 | |
2486 | /** |
2487 | * Place Storage Retrieval Request (Call Slip) |
2488 | * |
2489 | * Attempts to place a call slip request on a particular item and returns |
2490 | * an array with result details |
2491 | * |
2492 | * @param array $details An array of item and patron data |
2493 | * |
2494 | * @return mixed An array of data on the request including |
2495 | * whether or not it was successful and a system message (if available) |
2496 | */ |
2497 | public function placeStorageRetrievalRequest($details) |
2498 | { |
2499 | $patron = $details['patron']; |
2500 | $level = isset($details['level']) && !empty($details['level']) |
2501 | ? $details['level'] : 'copy'; |
2502 | $itemId = $details['item_id'] ?? false; |
2503 | $mfhdId = $details['holdings_id'] ?? false; |
2504 | $comment = $details['comment'] ?? ''; |
2505 | $bibId = $details['id']; |
2506 | |
2507 | // Make Sure Pick Up Location is Valid |
2508 | if ( |
2509 | isset($details['pickUpLocation']) |
2510 | && !$this->pickUpLocationIsValid( |
2511 | $details['pickUpLocation'], |
2512 | $patron, |
2513 | $details |
2514 | ) |
2515 | ) { |
2516 | return $this->holdError('hold_invalid_pickup'); |
2517 | } |
2518 | |
2519 | // Attempt Request |
2520 | $hierarchy = []; |
2521 | |
2522 | // Build Hierarchy |
2523 | $hierarchy['record'] = $bibId; |
2524 | |
2525 | if ($itemId && $level != 'title') { |
2526 | $hierarchy['items'] = $itemId; |
2527 | } |
2528 | |
2529 | $hierarchy['callslip'] = false; |
2530 | |
2531 | // Add Required Params |
2532 | $params = [ |
2533 | 'patron' => $patron['id'], |
2534 | 'patron_homedb' => $this->ws_patronHomeUbId, |
2535 | 'view' => 'full', |
2536 | ]; |
2537 | |
2538 | $xml = []; |
2539 | if ('title' == $level) { |
2540 | $xml['call-slip-title-parameters'] = [ |
2541 | 'comment' => $comment, |
2542 | 'reqinput field="1"' => $details['volume'], |
2543 | 'reqinput field="2"' => $details['issue'], |
2544 | 'reqinput field="3"' => $details['year'], |
2545 | 'dbkey' => $this->ws_dbKey, |
2546 | 'mfhdId' => $mfhdId, |
2547 | ]; |
2548 | if (isset($details['pickUpLocation'])) { |
2549 | $xml['call-slip-title-parameters']['pickup-location'] |
2550 | = $details['pickUpLocation']; |
2551 | } |
2552 | } else { |
2553 | $xml['call-slip-parameters'] = [ |
2554 | 'comment' => $comment, |
2555 | 'dbkey' => $this->ws_dbKey, |
2556 | ]; |
2557 | if (isset($details['pickUpLocation'])) { |
2558 | $xml['call-slip-parameters']['pickup-location'] |
2559 | = $details['pickUpLocation']; |
2560 | } |
2561 | } |
2562 | |
2563 | // Generate XML |
2564 | $requestXML = $this->buildBasicXML($xml); |
2565 | |
2566 | // Get Data |
2567 | $result = $this->makeRequest($hierarchy, $params, 'PUT', $requestXML); |
2568 | |
2569 | if ($result) { |
2570 | // Process |
2571 | $result = $result->children(); |
2572 | $reply = (string)$result->{'reply-text'}; |
2573 | |
2574 | $responseNode = 'title' == $level |
2575 | ? 'create-call-slip-title' |
2576 | : 'create-call-slip'; |
2577 | $note = (isset($result->$responseNode)) |
2578 | ? trim((string)$result->$responseNode->note) : false; |
2579 | |
2580 | // Valid Response |
2581 | $response = []; |
2582 | if ($reply == 'ok' && $note == 'Your request was successful.') { |
2583 | $response['success'] = true; |
2584 | $response['status'] = 'storage_retrieval_request_place_success'; |
2585 | } else { |
2586 | // Failed |
2587 | $response['sysMessage'] = $note; |
2588 | } |
2589 | return $response; |
2590 | } |
2591 | |
2592 | return $this->holdError('storage_retrieval_request_error_blocked'); |
2593 | } |
2594 | |
2595 | /** |
2596 | * Cancel Storage Retrieval Requests (Call Slips) |
2597 | * |
2598 | * Attempts to Cancel a call slip on a particular item. The |
2599 | * data in $cancelDetails['details'] is determined by |
2600 | * getCancelStorageRetrievalRequestDetails(). |
2601 | * |
2602 | * @param array $cancelDetails An array of item and patron data |
2603 | * |
2604 | * @return array An array of data on each request including |
2605 | * whether or not it was successful and a system message (if available) |
2606 | */ |
2607 | public function cancelStorageRetrievalRequests($cancelDetails) |
2608 | { |
2609 | $details = $cancelDetails['details']; |
2610 | $patron = $cancelDetails['patron']; |
2611 | $count = 0; |
2612 | $response = []; |
2613 | |
2614 | foreach ($details as $cancelDetails) { |
2615 | [$dbKey, $itemId, $cancelCode] = explode('|', $cancelDetails); |
2616 | |
2617 | // Create Rest API Cancel Key |
2618 | $cancelID = ($dbKey ? $dbKey : $this->ws_dbKey) . '|' . $cancelCode; |
2619 | |
2620 | // Build Hierarchy |
2621 | $hierarchy = [ |
2622 | 'patron' => $patron['id'], |
2623 | 'circulationActions' => 'requests', |
2624 | 'callslips' => $cancelID, |
2625 | ]; |
2626 | |
2627 | // Add Required Params |
2628 | $params = [ |
2629 | 'patron_homedb' => $this->ws_patronHomeUbId, |
2630 | 'view' => 'full', |
2631 | ]; |
2632 | |
2633 | // Get Data |
2634 | $cancel = $this->makeRequest($hierarchy, $params, 'DELETE'); |
2635 | |
2636 | if ($cancel) { |
2637 | // Process Cancel |
2638 | $cancel = $cancel->children(); |
2639 | $reply = (string)$cancel->{'reply-text'}; |
2640 | $count = ($reply == 'ok') ? $count + 1 : $count; |
2641 | |
2642 | $response[$itemId] = [ |
2643 | 'success' => ($reply == 'ok') ? true : false, |
2644 | 'status' => ($reply == 'ok') |
2645 | ? 'storage_retrieval_request_cancel_success' |
2646 | : 'storage_retrieval_request_cancel_fail', |
2647 | 'sysMessage' => ($reply == 'ok') ? false : $reply, |
2648 | ]; |
2649 | } else { |
2650 | $response[$itemId] = [ |
2651 | 'success' => false, |
2652 | 'status' => 'storage_retrieval_request_cancel_fail', |
2653 | ]; |
2654 | } |
2655 | } |
2656 | $result = ['count' => $count, 'items' => $response]; |
2657 | return $result; |
2658 | } |
2659 | |
2660 | /** |
2661 | * Get Cancel Storage Retrieval Request (Call Slip) Details |
2662 | * |
2663 | * In order to cancel a call slip, Voyager requires the item ID and a |
2664 | * request ID. This function returns the item id and call slip id as a |
2665 | * string separated by a pipe, which is then submitted as form data. This |
2666 | * value is then extracted by the CancelStorageRetrievalRequests function. |
2667 | * |
2668 | * @param array $details An array of item data |
2669 | * @param array $patron Patron information from patronLogin |
2670 | * |
2671 | * @return string Data for use in a form field |
2672 | * |
2673 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
2674 | */ |
2675 | public function getCancelStorageRetrievalRequestDetails($details, $patron) |
2676 | { |
2677 | $details |
2678 | = ($details['institution_dbkey'] ?? '') |
2679 | . '|' . $details['item_id'] |
2680 | . '|' . $details['reqnum']; |
2681 | return $details; |
2682 | } |
2683 | |
2684 | /** |
2685 | * A helper function that retrieves UB request details for ILL and caches them |
2686 | * for a short while for faster access. |
2687 | * |
2688 | * @param string $id BIB id |
2689 | * @param array $patron Patron |
2690 | * |
2691 | * @return bool|array False if UB request is not available or an array |
2692 | * of details on success |
2693 | */ |
2694 | protected function getUBRequestDetails($id, $patron) |
2695 | { |
2696 | $cacheId = "ub|$id|{$patron['id']}"; |
2697 | $data = $this->getCachedData($cacheId); |
2698 | if (!empty($data)) { |
2699 | return $data; |
2700 | } |
2701 | |
2702 | if (!str_contains($patron['id'], '.')) { |
2703 | $this->debug( |
2704 | "getUBRequestDetails: no prefix in patron id '{$patron['id']}'" |
2705 | ); |
2706 | $this->putCachedData($cacheId, false); |
2707 | return false; |
2708 | } |
2709 | [$source, $patronId] = explode('.', $patron['id'], 2); |
2710 | if (!isset($this->config['ILLRequestSources'][$source])) { |
2711 | $this->debug("getUBRequestDetails: source '$source' unknown"); |
2712 | $this->putCachedData($cacheId, false); |
2713 | return false; |
2714 | } |
2715 | |
2716 | [, $catUsername] = explode('.', $patron['cat_username'], 2); |
2717 | $patronId = $this->encodeXML($patronId); |
2718 | $patronHomeUbId = $this->encodeXML( |
2719 | $this->config['ILLRequestSources'][$source] |
2720 | ); |
2721 | $lastname = $this->encodeXML($patron['lastname']); |
2722 | $barcode = $this->encodeXML($catUsername); |
2723 | $bibId = $this->encodeXML($id); |
2724 | $bibDbName = $this->encodeXML($this->config['Catalog']['database']); |
2725 | $localUbId = $this->encodeXML($this->ws_patronHomeUbId); |
2726 | |
2727 | // Call PatronRequestsService first to check that UB is an available request |
2728 | // type. Additionally, this seems to be mandatory, as PatronRequestService |
2729 | // may fail otherwise. |
2730 | $xml = <<<EOT |
2731 | <?xml version="1.0" encoding="UTF-8"?> |
2732 | <ser:serviceParameters |
2733 | xmlns:ser="http://www.endinfosys.com/Voyager/serviceParameters"> |
2734 | <ser:parameters> |
2735 | <ser:parameter key="bibId"> |
2736 | <ser:value>$bibId</ser:value> |
2737 | </ser:parameter> |
2738 | <ser:parameter key="bibDbCode"> |
2739 | <ser:value>LOCAL</ser:value> |
2740 | </ser:parameter> |
2741 | </ser:parameters> |
2742 | <ser:patronIdentifier lastName="$lastname" patronHomeUbId="$patronHomeUbId" |
2743 | patronId="$patronId"> |
2744 | <ser:authFactor type="B">$barcode</ser:authFactor> |
2745 | </ser:patronIdentifier> |
2746 | </ser:serviceParameters> |
2747 | EOT; |
2748 | |
2749 | $response = $this->makeRequest( |
2750 | ['PatronRequestsService' => false], |
2751 | [], |
2752 | 'POST', |
2753 | $xml |
2754 | ); |
2755 | |
2756 | if ($response === false) { |
2757 | $this->putCachedData($cacheId, false); |
2758 | return false; |
2759 | } |
2760 | // Process |
2761 | $response->registerXPathNamespace( |
2762 | 'ser', |
2763 | 'http://www.endinfosys.com/Voyager/serviceParameters' |
2764 | ); |
2765 | $response->registerXPathNamespace( |
2766 | 'req', |
2767 | 'http://www.endinfosys.com/Voyager/requests' |
2768 | ); |
2769 | foreach ($response->xpath('//ser:message') as $message) { |
2770 | // Any message means a problem, right? |
2771 | $this->putCachedData($cacheId, false); |
2772 | return false; |
2773 | } |
2774 | $requestCount = count( |
2775 | $response->xpath("//req:requestIdentifier[@requestCode='UB']") |
2776 | ); |
2777 | if ($requestCount == 0) { |
2778 | // UB request not available |
2779 | $this->putCachedData($cacheId, false); |
2780 | return false; |
2781 | } |
2782 | |
2783 | $xml = <<<EOT |
2784 | <?xml version="1.0" encoding="UTF-8"?> |
2785 | <ser:serviceParameters |
2786 | xmlns:ser="http://www.endinfosys.com/Voyager/serviceParameters"> |
2787 | <ser:parameters> |
2788 | <ser:parameter key="bibId"> |
2789 | <ser:value>$bibId</ser:value> |
2790 | </ser:parameter> |
2791 | <ser:parameter key="bibDbCode"> |
2792 | <ser:value>LOCAL</ser:value> |
2793 | </ser:parameter> |
2794 | <ser:parameter key="bibDbName"> |
2795 | <ser:value>$bibDbName</ser:value> |
2796 | </ser:parameter> |
2797 | <ser:parameter key="requestCode"> |
2798 | <ser:value>UB</ser:value> |
2799 | </ser:parameter> |
2800 | <ser:parameter key="requestSiteId"> |
2801 | <ser:value>$localUbId</ser:value> |
2802 | </ser:parameter> |
2803 | </ser:parameters> |
2804 | <ser:patronIdentifier lastName="$lastname" patronHomeUbId="$patronHomeUbId" |
2805 | patronId="$patronId"> |
2806 | <ser:authFactor type="B">$barcode</ser:authFactor> |
2807 | </ser:patronIdentifier> |
2808 | </ser:serviceParameters> |
2809 | EOT; |
2810 | |
2811 | $response = $this->makeRequest( |
2812 | ['PatronRequestService' => false], |
2813 | [], |
2814 | 'POST', |
2815 | $xml |
2816 | ); |
2817 | |
2818 | if ($response === false) { |
2819 | $this->putCachedData($cacheId, false); |
2820 | return false; |
2821 | } |
2822 | // Process |
2823 | $response->registerXPathNamespace( |
2824 | 'ser', |
2825 | 'http://www.endinfosys.com/Voyager/serviceParameters' |
2826 | ); |
2827 | $response->registerXPathNamespace( |
2828 | 'req', |
2829 | 'http://www.endinfosys.com/Voyager/requests' |
2830 | ); |
2831 | foreach ($response->xpath('//ser:message') as $message) { |
2832 | // Any message means a problem, right? |
2833 | $this->putCachedData($cacheId, false); |
2834 | return false; |
2835 | } |
2836 | $items = []; |
2837 | $libraries = []; |
2838 | $locations = []; |
2839 | $requiredByDate = ''; |
2840 | foreach ($response->xpath('//req:field') as $field) { |
2841 | switch ($field->attributes()->labelKey) { |
2842 | case 'selectItem': |
2843 | foreach ($field->xpath('./req:select/req:option') as $option) { |
2844 | $items[] = [ |
2845 | 'id' => (string)$option->attributes()->id, |
2846 | 'name' => (string)$option, |
2847 | ]; |
2848 | } |
2849 | break; |
2850 | case 'pickupLib': |
2851 | foreach ($field->xpath('./req:select/req:option') as $option) { |
2852 | $libraries[] = [ |
2853 | 'id' => (string)$option->attributes()->id, |
2854 | 'name' => (string)$option, |
2855 | 'isDefault' => $option->attributes()->isDefault == 'Y', |
2856 | ]; |
2857 | } |
2858 | break; |
2859 | case 'pickUpAt': |
2860 | foreach ($field->xpath('./req:select/req:option') as $option) { |
2861 | $locations[] = [ |
2862 | 'id' => (string)$option->attributes()->id, |
2863 | 'name' => (string)$option, |
2864 | 'isDefault' => $option->attributes()->isDefault == 'Y', |
2865 | ]; |
2866 | } |
2867 | break; |
2868 | case 'notNeededAfter': |
2869 | $node = current($field->xpath('./req:text')); |
2870 | $requiredByDate = $this->dateFormat->convertToDisplayDate( |
2871 | 'Y-m-d H:i', |
2872 | (string)$node |
2873 | ); |
2874 | break; |
2875 | } |
2876 | } |
2877 | $results = [ |
2878 | 'items' => $items, |
2879 | 'libraries' => $libraries, |
2880 | 'locations' => $locations, |
2881 | 'requiredBy' => $requiredByDate, |
2882 | ]; |
2883 | $this->putCachedData($cacheId, $results); |
2884 | return $results; |
2885 | } |
2886 | |
2887 | /** |
2888 | * Check if ILL Request is valid |
2889 | * |
2890 | * This is responsible for determining if an item is requestable |
2891 | * |
2892 | * @param string $id The Bib ID |
2893 | * @param array $data An Array of item data |
2894 | * @param array $patron An array of patron data |
2895 | * |
2896 | * @return bool True if request is valid, false if not |
2897 | */ |
2898 | public function checkILLRequestIsValid($id, $data, $patron) |
2899 | { |
2900 | if (!isset($this->config['ILLRequests'])) { |
2901 | $this->debug('ILL Requests not configured'); |
2902 | return false; |
2903 | } |
2904 | |
2905 | $level = $data['level'] ?? 'copy'; |
2906 | $itemID = ($level != 'title' && isset($data['item_id'])) |
2907 | ? $data['item_id'] |
2908 | : false; |
2909 | |
2910 | if ($level == 'copy' && $itemID === false) { |
2911 | $this->debug('Item ID missing'); |
2912 | return false; |
2913 | } |
2914 | |
2915 | $results = $this->getUBRequestDetails($id, $patron); |
2916 | if ($results === false) { |
2917 | $this->debug('getUBRequestDetails returned false'); |
2918 | return false; |
2919 | } |
2920 | if ($level == 'copy') { |
2921 | $found = false; |
2922 | foreach ($results['items'] as $item) { |
2923 | if ($item['id'] == "$itemID.$id") { |
2924 | $found = true; |
2925 | break; |
2926 | } |
2927 | } |
2928 | if (!$found) { |
2929 | $this->debug('Item not requestable'); |
2930 | return false; |
2931 | } |
2932 | } |
2933 | |
2934 | return true; |
2935 | } |
2936 | |
2937 | /** |
2938 | * Get ILL (UB) Pickup Libraries |
2939 | * |
2940 | * This is responsible for getting information on the possible pickup libraries |
2941 | * |
2942 | * @param string $id Record ID |
2943 | * @param array $patron Patron |
2944 | * |
2945 | * @return bool|array False if request not allowed, or an array of associative |
2946 | * arrays with libraries. |
2947 | */ |
2948 | public function getILLPickupLibraries($id, $patron) |
2949 | { |
2950 | if (!isset($this->config['ILLRequests'])) { |
2951 | return false; |
2952 | } |
2953 | |
2954 | $results = $this->getUBRequestDetails($id, $patron); |
2955 | if ($results === false) { |
2956 | $this->debug('getUBRequestDetails returned false'); |
2957 | return false; |
2958 | } |
2959 | |
2960 | return $results['libraries']; |
2961 | } |
2962 | |
2963 | /** |
2964 | * Get ILL (UB) Pickup Locations |
2965 | * |
2966 | * This is responsible for getting a list of possible pickup locations for a |
2967 | * library |
2968 | * |
2969 | * @param string $id Record ID |
2970 | * @param string $pickupLib Pickup library ID |
2971 | * @param array $patron Patron |
2972 | * |
2973 | * @return bool|array False if request not allowed, or an array of |
2974 | * locations. |
2975 | * |
2976 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
2977 | */ |
2978 | public function getILLPickupLocations($id, $pickupLib, $patron) |
2979 | { |
2980 | if (!isset($this->config['ILLRequests'])) { |
2981 | return false; |
2982 | } |
2983 | |
2984 | [$source, $patronId] = explode('.', $patron['id'], 2); |
2985 | if (!isset($this->config['ILLRequestSources'][$source])) { |
2986 | return $this->holdError('ill_request_unknown_patron_source'); |
2987 | } |
2988 | |
2989 | [, $catUsername] = explode('.', $patron['cat_username'], 2); |
2990 | $patronId = $this->encodeXML($patronId); |
2991 | $patronHomeUbId = $this->encodeXML( |
2992 | $this->config['ILLRequestSources'][$source] |
2993 | ); |
2994 | $lastname = $this->encodeXML($patron['lastname']); |
2995 | $barcode = $this->encodeXML($catUsername); |
2996 | $pickupLib = $this->encodeXML($pickupLib); |
2997 | |
2998 | $xml = <<<EOT |
2999 | <?xml version="1.0" encoding="UTF-8"?> |
3000 | <ser:serviceParameters |
3001 | xmlns:ser="http://www.endinfosys.com/Voyager/serviceParameters"> |
3002 | <ser:parameters> |
3003 | <ser:parameter key="pickupLibId"> |
3004 | <ser:value>$pickupLib</ser:value> |
3005 | </ser:parameter> |
3006 | </ser:parameters> |
3007 | <ser:patronIdentifier lastName="$lastname" patronHomeUbId="$patronHomeUbId" |
3008 | patronId="$patronId"> |
3009 | <ser:authFactor type="B">$barcode</ser:authFactor> |
3010 | </ser:patronIdentifier> |
3011 | </ser:serviceParameters> |
3012 | EOT; |
3013 | |
3014 | $response = $this->makeRequest( |
3015 | ['UBPickupLibService' => false], |
3016 | [], |
3017 | 'POST', |
3018 | $xml |
3019 | ); |
3020 | |
3021 | if ($response === false) { |
3022 | throw new ILSException('ill_request_error_technical'); |
3023 | } |
3024 | // Process |
3025 | $response->registerXPathNamespace( |
3026 | 'ser', |
3027 | 'http://www.endinfosys.com/Voyager/serviceParameters' |
3028 | ); |
3029 | $response->registerXPathNamespace( |
3030 | 'req', |
3031 | 'http://www.endinfosys.com/Voyager/requests' |
3032 | ); |
3033 | if ($response->xpath('//ser:message')) { |
3034 | // Any message means a problem, right? |
3035 | throw new ILSException('ill_request_error_technical'); |
3036 | } |
3037 | $locations = []; |
3038 | foreach ($response->xpath('//req:location') as $location) { |
3039 | $locations[] = [ |
3040 | 'id' => (string)$location->attributes()->id, |
3041 | 'name' => (string)$location, |
3042 | 'isDefault' => $location->attributes()->isDefault == 'Y', |
3043 | ]; |
3044 | } |
3045 | return $locations; |
3046 | } |
3047 | |
3048 | /** |
3049 | * Place ILL (UB) Request |
3050 | * |
3051 | * Attempts to place an UB request on a particular item and returns |
3052 | * an array with result details or a PEAR error on failure of support classes |
3053 | * |
3054 | * @param array $details An array of item and patron data |
3055 | * |
3056 | * @return mixed An array of data on the request including |
3057 | * whether or not it was successful and a system message (if available) |
3058 | */ |
3059 | public function placeILLRequest($details) |
3060 | { |
3061 | $patron = $details['patron']; |
3062 | [$source, $patronId] = explode('.', $patron['id'], 2); |
3063 | if (!isset($this->config['ILLRequestSources'][$source])) { |
3064 | return $this->holdError('ill_request_error_unknown_patron_source'); |
3065 | } |
3066 | |
3067 | [, $catUsername] = explode('.', $patron['cat_username'], 2); |
3068 | $patronId = htmlspecialchars($patronId, ENT_COMPAT, 'UTF-8'); |
3069 | $patronHomeUbId = $this->encodeXML( |
3070 | $this->config['ILLRequestSources'][$source] |
3071 | ); |
3072 | $lastname = $this->encodeXML($patron['lastname']); |
3073 | $ubId = $this->encodeXML($patronHomeUbId); |
3074 | $barcode = $this->encodeXML($catUsername); |
3075 | $pickupLocation = $this->encodeXML($details['pickUpLibraryLocation']); |
3076 | $pickupLibrary = $this->encodeXML($details['pickUpLibrary']); |
3077 | $itemId = $this->encodeXML($details['item_id'] . '.' . $details['id']); |
3078 | $comment = $this->encodeXML( |
3079 | $details['comment'] ?? '' |
3080 | ); |
3081 | $bibId = $this->encodeXML($details['id']); |
3082 | $bibDbName = $this->encodeXML($this->config['Catalog']['database']); |
3083 | $localUbId = $this->encodeXML($this->ws_patronHomeUbId); |
3084 | |
3085 | // Convert last interest date from Display Format to Voyager required format |
3086 | try { |
3087 | $lastInterestDate = $this->dateFormat->convertFromDisplayDate( |
3088 | 'Y-m-d', |
3089 | $details['requiredBy'] |
3090 | ); |
3091 | } catch (DateException $e) { |
3092 | // Date is invalid |
3093 | return $this->holdError('ill_request_date_invalid'); |
3094 | } |
3095 | |
3096 | // Verify pickup library and location |
3097 | $pickupLocationValid = false; |
3098 | $pickupLocations = $this->getILLPickupLocations( |
3099 | $details['id'], |
3100 | $details['pickUpLibrary'], |
3101 | $patron |
3102 | ); |
3103 | foreach ($pickupLocations as $location) { |
3104 | if ($location['id'] == $details['pickUpLibraryLocation']) { |
3105 | $pickupLocationValid = true; |
3106 | break; |
3107 | } |
3108 | } |
3109 | if (!$pickupLocationValid) { |
3110 | return [ |
3111 | 'success' => false, |
3112 | 'sysMessage' => 'ill_request_place_fail_missing', |
3113 | ]; |
3114 | } |
3115 | |
3116 | // Attempt Request |
3117 | $xml = <<<EOT |
3118 | <?xml version="1.0" encoding="UTF-8"?> |
3119 | <ser:serviceParameters |
3120 | xmlns:ser="http://www.endinfosys.com/Voyager/serviceParameters"> |
3121 | <ser:parameters> |
3122 | <ser:parameter key="bibId"> |
3123 | <ser:value>$bibId</ser:value> |
3124 | </ser:parameter> |
3125 | <ser:parameter key="bibDbCode"> |
3126 | <ser:value>LOCAL</ser:value> |
3127 | </ser:parameter> |
3128 | <ser:parameter key="bibDbName"> |
3129 | <ser:value>$bibDbName</ser:value> |
3130 | </ser:parameter> |
3131 | <ser:parameter key="Select_Library"> |
3132 | <ser:value>$localUbId</ser:value> |
3133 | </ser:parameter> |
3134 | <ser:parameter key="requestCode"> |
3135 | <ser:value>UB</ser:value> |
3136 | </ser:parameter> |
3137 | <ser:parameter key="requestSiteId"> |
3138 | <ser:value>$localUbId</ser:value> |
3139 | </ser:parameter> |
3140 | <ser:parameter key="itemId"> |
3141 | <ser:value>$itemId</ser:value> |
3142 | </ser:parameter> |
3143 | <ser:parameter key="Select_Pickup_Lib"> |
3144 | <ser:value>$pickupLibrary</ser:value> |
3145 | </ser:parameter> |
3146 | <ser:parameter key="PICK"> |
3147 | <ser:value>$pickupLocation</ser:value> |
3148 | </ser:parameter> |
3149 | <ser:parameter key="REQNNA"> |
3150 | <ser:value>$lastInterestDate</ser:value> |
3151 | </ser:parameter> |
3152 | <ser:parameter key="REQCOMMENTS"> |
3153 | <ser:value>$comment</ser:value> |
3154 | </ser:parameter> |
3155 | </ser:parameters> |
3156 | <ser:patronIdentifier lastName="$lastname" patronHomeUbId="$ubId" |
3157 | patronId="$patronId"> |
3158 | <ser:authFactor type="B">$barcode</ser:authFactor> |
3159 | </ser:patronIdentifier> |
3160 | </ser:serviceParameters> |
3161 | EOT; |
3162 | |
3163 | $response = $this->makeRequest( |
3164 | ['SendPatronRequestService' => false], |
3165 | [], |
3166 | 'POST', |
3167 | $xml |
3168 | ); |
3169 | |
3170 | if ($response === false) { |
3171 | return $this->holdError('ill_request_error_technical'); |
3172 | } |
3173 | // Process |
3174 | $response->registerXPathNamespace( |
3175 | 'ser', |
3176 | 'http://www.endinfosys.com/Voyager/serviceParameters' |
3177 | ); |
3178 | $response->registerXPathNamespace( |
3179 | 'req', |
3180 | 'http://www.endinfosys.com/Voyager/requests' |
3181 | ); |
3182 | foreach ($response->xpath('//ser:message') as $message) { |
3183 | if ($message->attributes()->type == 'success') { |
3184 | return [ |
3185 | 'success' => true, |
3186 | 'status' => 'ill_request_place_success', |
3187 | ]; |
3188 | } |
3189 | if ($message->attributes()->type == 'system') { |
3190 | return $this->holdError('ill_request_error_technical'); |
3191 | } |
3192 | } |
3193 | |
3194 | return $this->holdError('ill_request_error_blocked'); |
3195 | } |
3196 | |
3197 | /** |
3198 | * Get Patron ILL Requests |
3199 | * |
3200 | * This is responsible for retrieving all UB requests by a specific patron. |
3201 | * |
3202 | * @param array $patron The patron array from patronLogin |
3203 | * |
3204 | * @throws ILSException |
3205 | * @return mixed Array of the patron's holds on success. |
3206 | */ |
3207 | public function getMyILLRequests($patron) |
3208 | { |
3209 | return array_merge( |
3210 | $this->getHoldsFromApi($patron, false), |
3211 | $this->getCallSlips($patron, false) // remote only |
3212 | ); |
3213 | } |
3214 | |
3215 | /** |
3216 | * Cancel ILL (UB) Requests |
3217 | * |
3218 | * Attempts to Cancel an UB request on a particular item. The |
3219 | * data in $cancelDetails['details'] is determined by |
3220 | * getCancelILLRequestDetails(). |
3221 | * |
3222 | * @param array $cancelDetails An array of item and patron data |
3223 | * |
3224 | * @return array An array of data on each request including |
3225 | * whether or not it was successful and a system message (if available) |
3226 | */ |
3227 | public function cancelILLRequests($cancelDetails) |
3228 | { |
3229 | $details = $cancelDetails['details']; |
3230 | $patron = $cancelDetails['patron']; |
3231 | $count = 0; |
3232 | $response = []; |
3233 | |
3234 | foreach ($details as $cancelDetails) { |
3235 | [$dbKey, $itemId, $type, $cancelCode] = explode('|', $cancelDetails); |
3236 | |
3237 | // Create Rest API Cancel Key |
3238 | $cancelID = ($dbKey ? $dbKey : $this->ws_dbKey) . '|' . $cancelCode; |
3239 | |
3240 | // Build Hierarchy |
3241 | $hierarchy = [ |
3242 | 'patron' => $patron['id'], |
3243 | 'circulationActions' => 'requests', |
3244 | ]; |
3245 | // An UB request is |
3246 | if ($type == 'C') { |
3247 | $hierarchy['callslips'] = $cancelID; |
3248 | } else { |
3249 | $hierarchy['holds'] = $cancelID; |
3250 | } |
3251 | |
3252 | // Add Required Params |
3253 | $params = [ |
3254 | 'patron_homedb' => $this->ws_patronHomeUbId, |
3255 | 'view' => 'full', |
3256 | ]; |
3257 | |
3258 | // Get Data |
3259 | $cancel = $this->makeRequest($hierarchy, $params, 'DELETE'); |
3260 | |
3261 | if ($cancel) { |
3262 | // Process Cancel |
3263 | $cancel = $cancel->children(); |
3264 | $node = 'reply-text'; |
3265 | $reply = (string)$cancel->$node; |
3266 | $count = ($reply == 'ok') ? $count + 1 : $count; |
3267 | |
3268 | $response[$itemId] = [ |
3269 | 'success' => ($reply == 'ok') ? true : false, |
3270 | 'status' => ($reply == 'ok') |
3271 | ? 'ill_request_cancel_success' : 'ill_request_cancel_fail', |
3272 | 'sysMessage' => ($reply == 'ok') ? false : $reply, |
3273 | ]; |
3274 | } else { |
3275 | $response[$itemId] = [ |
3276 | 'success' => false, |
3277 | 'status' => 'ill_request_cancel_fail', |
3278 | ]; |
3279 | } |
3280 | } |
3281 | $result = ['count' => $count, 'items' => $response]; |
3282 | return $result; |
3283 | } |
3284 | |
3285 | /** |
3286 | * Get Cancel ILL (UB) Request Details |
3287 | * |
3288 | * In Voyager an UB request is either a call slip (pending delivery) or a hold |
3289 | * (pending checkout). In order to cancel an UB request, Voyager requires the |
3290 | * patron details, an item ID, request type and a recall ID. This function |
3291 | * returns the information as a string separated by pipes, which is then |
3292 | * submitted as form data and extracted by the CancelILLRequests function. |
3293 | * |
3294 | * @param array $details An array of item data |
3295 | * @param array $patron Patron information from patronLogin |
3296 | * |
3297 | * @return string Data for use in a form field |
3298 | * |
3299 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
3300 | */ |
3301 | public function getCancelILLRequestDetails($details, $patron) |
3302 | { |
3303 | $details = ($details['institution_dbkey'] ?? '') |
3304 | . '|' . $details['item_id'] |
3305 | . '|' . $details['type'] |
3306 | . '|' . $details['reqnum']; |
3307 | return $details; |
3308 | } |
3309 | |
3310 | /** |
3311 | * Support method: is this institution code a local one? |
3312 | * |
3313 | * @param string $institution Institution code |
3314 | * |
3315 | * @return bool |
3316 | */ |
3317 | protected function isLocalInst($institution) |
3318 | { |
3319 | // In some versions of Voyager, this will be 'LOCAL' while |
3320 | // in others, it may be something like '1@LOCAL' -- for now, |
3321 | // let's try checking the last 5 characters. If other options |
3322 | // exist in the wild, we can make this method more sophisticated. |
3323 | return str_ends_with($institution, 'LOCAL'); |
3324 | } |
3325 | |
3326 | /** |
3327 | * Change Password |
3328 | * |
3329 | * Attempts to change patron password (PIN code) |
3330 | * |
3331 | * @param array $details An array of patron id and old and new password: |
3332 | * |
3333 | * 'patron' The patron array from patronLogin |
3334 | * 'oldPassword' Old password |
3335 | * 'newPassword' New password |
3336 | * |
3337 | * @return array An array of data on the request including |
3338 | * whether or not it was successful and a system message (if available) |
3339 | */ |
3340 | public function changePassword($details) |
3341 | { |
3342 | $patron = $details['patron']; |
3343 | $id = htmlspecialchars($patron['id'], ENT_COMPAT, 'UTF-8'); |
3344 | $lastname = htmlspecialchars($patron['lastname'], ENT_COMPAT, 'UTF-8'); |
3345 | $ubId = htmlspecialchars($this->ws_patronHomeUbId, ENT_COMPAT, 'UTF-8'); |
3346 | $oldPIN = trim( |
3347 | htmlspecialchars( |
3348 | $this->sanitizePIN($details['oldPassword']), |
3349 | ENT_COMPAT, |
3350 | 'UTF-8' |
3351 | ) |
3352 | ); |
3353 | |
3354 | if ($oldPIN === '') { |
3355 | // Voyager requires the PIN code to be set even if it was empty |
3356 | $oldPIN = ' '; |
3357 | |
3358 | // In this case we have to check that the user didn't previously have a |
3359 | // PIN code since Voyager doesn't validate the 'empty' old PIN |
3360 | $sql = "SELECT PATRON_PIN FROM {$this->dbName}.PATRON WHERE" |
3361 | . ' PATRON_ID=:id'; |
3362 | $sqlStmt = $this->executeSQL($sql, ['id' => $patron['id']]); |
3363 | if ( |
3364 | !($row = $sqlStmt->fetch(PDO::FETCH_ASSOC)) |
3365 | || null !== $row['PATRON_PIN'] |
3366 | ) { |
3367 | return [ |
3368 | 'success' => false, 'status' => 'authentication_error_invalid', |
3369 | ]; |
3370 | } |
3371 | } |
3372 | $newPIN = trim( |
3373 | htmlspecialchars( |
3374 | $this->sanitizePIN($details['newPassword']), |
3375 | ENT_COMPAT, |
3376 | 'UTF-8' |
3377 | ) |
3378 | ); |
3379 | if ($newPIN === '') { |
3380 | return [ |
3381 | 'success' => false, 'status' => 'password_error_invalid', |
3382 | ]; |
3383 | } |
3384 | $barcode = htmlspecialchars($patron['cat_username'], ENT_COMPAT, 'UTF-8'); |
3385 | |
3386 | $xml = <<<EOT |
3387 | <?xml version="1.0" encoding="UTF-8"?> |
3388 | <ser:serviceParameters |
3389 | xmlns:ser="http://www.endinfosys.com/Voyager/serviceParameters"> |
3390 | <ser:parameters> |
3391 | <ser:parameter key="oldPatronPIN"> |
3392 | <ser:value>$oldPIN</ser:value> |
3393 | </ser:parameter> |
3394 | <ser:parameter key="newPatronPIN"> |
3395 | <ser:value>$newPIN</ser:value> |
3396 | </ser:parameter> |
3397 | </ser:parameters> |
3398 | <ser:patronIdentifier lastName="$lastname" patronHomeUbId="$ubId" patronId="$id"> |
3399 | <ser:authFactor type="B">$barcode</ser:authFactor> |
3400 | </ser:patronIdentifier> |
3401 | </ser:serviceParameters> |
3402 | EOT; |
3403 | |
3404 | $result = $this->makeRequest( |
3405 | ['ChangePINService' => false], |
3406 | [], |
3407 | 'POST', |
3408 | $xml |
3409 | ); |
3410 | |
3411 | $result->registerXPathNamespace( |
3412 | 'ser', |
3413 | 'http://www.endinfosys.com/Voyager/serviceParameters' |
3414 | ); |
3415 | $error = $result->xpath("//ser:message[@type='error']"); |
3416 | if (!empty($error)) { |
3417 | $error = reset($error); |
3418 | $code = $error->attributes()->errorCode; |
3419 | $exceptionNamespace = 'com.endinfosys.voyager.patronpin.PatronPIN.'; |
3420 | if ($code == $exceptionNamespace . 'ValidateException') { |
3421 | return [ |
3422 | 'success' => false, 'status' => 'authentication_error_invalid', |
3423 | ]; |
3424 | } |
3425 | if ($code == $exceptionNamespace . 'ValidateUniqueException') { |
3426 | return [ |
3427 | 'success' => false, 'status' => 'password_error_not_unique', |
3428 | ]; |
3429 | } |
3430 | if ($code == $exceptionNamespace . 'ValidateLengthException') { |
3431 | // This error may happen even with correct settings if the new PIN |
3432 | // contains invalid characters. |
3433 | return [ |
3434 | 'success' => false, 'status' => 'password_error_invalid', |
3435 | ]; |
3436 | } |
3437 | throw new ILSException((string)$error); |
3438 | } |
3439 | return ['success' => true, 'status' => 'change_password_ok']; |
3440 | } |
3441 | |
3442 | /** |
3443 | * Helper method to determine whether or not a certain method can be |
3444 | * called on this driver. Required method for any smart drivers. |
3445 | * |
3446 | * @param string $method The name of the called method. |
3447 | * @param array $params Array of passed parameters |
3448 | * |
3449 | * @return bool True if the method can be called with the given parameters, |
3450 | * false otherwise. |
3451 | * |
3452 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
3453 | */ |
3454 | public function supportsMethod($method, $params) |
3455 | { |
3456 | // Special case: change password is only available if properly configured. |
3457 | if ($method == 'changePassword') { |
3458 | return isset($this->config['changePassword']); |
3459 | } |
3460 | return is_callable([$this, $method]); |
3461 | } |
3462 | } |