Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.26% covered (danger)
0.26%
4 / 1549
1.69% covered (danger)
1.69%
1 / 59
CRAP
0.00% covered (danger)
0.00%
0 / 1
VoyagerRestful
0.26% covered (danger)
0.26%
4 / 1549
1.69% covered (danger)
1.69%
1 / 59
133286.93
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 init
2.27% covered (danger)
2.27%
1 / 44
0.00% covered (danger)
0.00%
0 / 1
28.33
 getConfig
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getCacheKey
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 isHoldable
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 isBorrowable
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 isStorageRetrievalRequestAllowed
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 isILLRequestAllowed
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHoldingItemsSQL
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 processHoldingRow
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 processHoldingData
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
462
 checkRequestIsValid
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
72
 checkStorageRetrievalRequestIsValid
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 processMyTransactionsData
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 pickUpLocationIsValid
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getPickUpLocations
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
210
 getDefaultPickUpLocation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDefaultRequestGroup
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 requestGroupSortFunction
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 getRequestGroups
0.00% covered (danger)
0.00%
0 / 94
0.00% covered (danger)
0.00%
0 / 1
90
 makeRequest
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
110
 encodeXML
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 buildBasicXML
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 extractBlockReasons
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getRequestBlocks
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAccountBlocks
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 checkAccountBlocks
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
42
 renewMyItems
0.00% covered (danger)
0.00%
0 / 91
0.00% covered (danger)
0.00%
0 / 1
272
 checkItemRequests
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
90
 makeItemRequests
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
156
 determineHoldType
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
56
 holdError
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 isRecordOnLoan
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
20
 itemsExist
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
30
 itemsAvailable
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
30
 getMyHoldsSQL
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 processMyHoldsData
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getMyHolds
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
90
 placeHold
0.00% covered (danger)
0.00%
0 / 74
0.00% covered (danger)
0.00%
0 / 1
1056
 cancelHolds
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
56
 getCancelHoldDetails
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 getRenewDetails
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getMyTransactions
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 1
306
 getHoldsFromApi
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
132
 getCallSlips
0.00% covered (danger)
0.00%
0 / 61
0.00% covered (danger)
0.00%
0 / 1
156
 placeStorageRetrievalRequest
0.00% covered (danger)
0.00%
0 / 60
0.00% covered (danger)
0.00%
0 / 1
240
 cancelStorageRetrievalRequests
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
72
 getCancelStorageRetrievalRequestDetails
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getUBRequestDetails
0.00% covered (danger)
0.00%
0 / 130
0.00% covered (danger)
0.00%
0 / 1
306
 checkILLRequestIsValid
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
132
 getILLPickupLibraries
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getILLPickupLocations
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
42
 placeILLRequest
0.00% covered (danger)
0.00%
0 / 81
0.00% covered (danger)
0.00%
0 / 1
110
 getMyILLRequests
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 cancelILLRequests
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
90
 getCancelILLRequestDetails
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 isLocalInst
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 changePassword
0.00% covered (danger)
0.00%
0 / 68
0.00% covered (danger)
0.00%
0 / 1
90
 supportsMethod
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
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
34namespace VuFind\ILS\Driver;
35
36use PDO;
37use PDOException;
38use VuFind\Date\DateException;
39use VuFind\Exception\ILS as ILSException;
40
41use function count;
42use function in_array;
43use 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 */
57class 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}