Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
9.83% covered (danger)
9.83%
73 / 743
2.94% covered (danger)
2.94%
1 / 34
CRAP
0.00% covered (danger)
0.00%
0 / 1
Symphony
9.83% covered (danger)
9.83%
73 / 743
2.94% covered (danger)
2.94%
1 / 34
23410.61
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 init
92.86% covered (success)
92.86%
39 / 42
0.00% covered (danger)
0.00%
0 / 1
3.00
 getSoapClient
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 getSoapHeader
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 getSessionToken
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 makeRequest
16.67% covered (danger)
16.67%
6 / 36
0.00% covered (danger)
0.00%
0 / 1
55.88
 checkSymwsVersion
20.00% covered (danger)
20.00%
1 / 5
0.00% covered (danger)
0.00%
0 / 1
12.19
 getStatuses999Holdings
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
72
 lookupTitleInfo
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 libraryIsFilteredOut
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 parseCallInfo
0.00% covered (danger)
0.00%
0 / 94
0.00% covered (danger)
0.00%
0 / 1
552
 parseBoundwithLinkInfo
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
110
 parseTitleOrderInfo
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
132
 parseMarcHoldingsInfo
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
90
 getLiveStatuses
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
72
 translatePolicyID
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getStatus
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getStatuses
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getHolding
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPurchaseHistory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 patronLogin
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
132
 getMyProfile
0.00% covered (danger)
0.00%
0 / 55
0.00% covered (danger)
0.00%
0 / 1
42
 getMyTransactions
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
72
 getMyHolds
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
42
 getMyFines
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
30
 getCancelHoldDetails
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 cancelHolds
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
12
 getConfig
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getRenewDetails
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 renewMyItems
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
12
 placeHold
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
56
 getPolicyList
54.55% covered (warning)
54.55%
12 / 22
0.00% covered (danger)
0.00%
0 / 1
11.60
 getPickUpLocations
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
2.75
 getDefaultPickUpLocation
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3/**
4 * Symphony Web Services (symws) ILS Driver
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2007.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  ILS_Drivers
25 * @author   Steven Hild <sjhild@wm.edu>
26 * @author   Michael Gillen <mlgillen@sfasu.edu>
27 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
28 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
29 */
30
31namespace VuFind\ILS\Driver;
32
33use Laminas\Log\LoggerAwareInterface;
34use SoapClient;
35use SoapFault;
36use SoapHeader;
37use VuFind\Cache\Manager as CacheManager;
38use VuFind\Exception\ILS as ILSException;
39use VuFind\Record\Loader;
40
41use function count;
42use function in_array;
43use function is_array;
44
45/**
46 * Symphony Web Services (symws) ILS Driver
47 *
48 * @category VuFind
49 * @package  ILS_Drivers
50 * @author   Steven Hild <sjhild@wm.edu>
51 * @author   Michael Gillen <mlgillen@sfasu.edu>
52 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
53 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
54 */
55class Symphony extends AbstractBase implements LoggerAwareInterface
56{
57    use \VuFind\Log\LoggerAwareTrait;
58
59    /**
60     * Cache for policy information
61     *
62     * @var object
63     */
64    protected $policyCache = false;
65
66    /**
67     * Policy information
68     *
69     * @var array
70     */
71    protected $policies;
72
73    /**
74     * Cache manager
75     *
76     * @var CacheManager
77     */
78    protected $cacheManager;
79
80    /**
81     * Record loader
82     *
83     * @var Loader
84     */
85    protected $recordLoader;
86
87    /**
88     * Constructor
89     *
90     * @param Loader       $loader       Record loader
91     * @param CacheManager $cacheManager Cache manager (optional)
92     */
93    public function __construct(Loader $loader, CacheManager $cacheManager = null)
94    {
95        $this->recordLoader = $loader;
96        $this->cacheManager = $cacheManager;
97    }
98
99    /**
100     * Initialize the driver.
101     *
102     * Validate configuration and perform all resource-intensive tasks needed to
103     * make the driver active.
104     *
105     * @throws ILSException
106     * @return void
107     */
108    public function init()
109    {
110        // Merge in defaults.
111        $this->config += [
112            'WebServices' => [],
113            'PolicyCache' => [],
114            'LibraryFilter' => [],
115            'MarcHoldings' => [],
116            '999Holdings' => [],
117            'Behaviors' => [],
118        ];
119
120        $this->config['WebServices'] += [
121            'clientID' => 'VuFind',
122            'baseURL' => 'http://localhost:8080/symws',
123            'soapOptions' => [],
124        ];
125
126        $this->config['PolicyCache'] += [
127            'backend' => 'file',
128            'backendOptions' => [],
129            'frontendOptions' => [],
130        ];
131
132        $this->config['PolicyCache']['frontendOptions'] += [
133            'automatic_serialization' => true,
134            'lifetime' => null,
135        ];
136
137        $this->config['LibraryFilter'] += [
138            'include_only' => [],
139            'exclude' => [],
140        ];
141
142        $this->config['999Holdings'] += [
143            'entry_number' => 999,
144            'mode' => 'off', // also off, failover
145        ];
146
147        $this->config['Behaviors'] += [
148            'showBaseCallNumber' => true,
149            'showAccountLogin' => true,
150            'showStaffNotes' => true,
151            'showFeeType' => 'ALL_FEES',
152            'usernameField' => 'userID',
153            'userProfileGroupField' => 'USER_PROFILE_ID',
154        ];
155
156        // Initialize cache manager.
157        if (
158            isset($this->config['PolicyCache']['type'])
159            && $this->cacheManager
160        ) {
161            $this->policyCache = $this->cacheManager
162                ->getCache($this->config['PolicyCache']['type']);
163        }
164    }
165
166    /**
167     * Return a SoapClient for the specified SymWS service.
168     *
169     * SoapClient instantiation fetches and parses remote files,
170     * so this method instantiates SoapClients lazily and keeps them around
171     * so that they can be reused for multiple requests.
172     *
173     * @param string $service The name of the SymWS service
174     *
175     * @return object The SoapClient object for the specified service
176     */
177    protected function getSoapClient($service)
178    {
179        static $soapClients = [];
180
181        if (!isset($soapClients[$service])) {
182            try {
183                $soapClients[$service] = new SoapClient(
184                    $this->config['WebServices']['baseURL'] . "/soap/$service?wsdl",
185                    $this->config['WebServices']['soapOptions']
186                );
187            } catch (SoapFault $e) {
188                // This SoapFault may have happened because, e.g., PHP's
189                // SoapClient won't load SymWS 3.1's Patron service WSDL.
190                // However, we can't check the SymWS version if this fault
191                // happened with the Standard service (which contains the
192                // 'version' operation).
193                if ($service != 'standard') {
194                    $this->checkSymwsVersion();
195                }
196
197                throw $e;
198            }
199        }
200
201        return $soapClients[$service];
202    }
203
204    /**
205     * Return a SoapHeader for the specified login and password.
206     *
207     * @param mixed $login    The login account name if logging in, otherwise null
208     * @param mixed $password The login password if logging in, otherwise null
209     * @param bool  $reset    Whether or not the session token should be reset
210     *
211     * @return object The SoapHeader object
212     */
213    protected function getSoapHeader(
214        $login = null,
215        $password = null,
216        $reset = false
217    ) {
218        $data = ['clientID' => $this->config['WebServices']['clientID']];
219        if (null !== $login) {
220            $data['sessionToken']
221                = $this->getSessionToken($login, $password, $reset);
222        }
223        return new SoapHeader(
224            'http://www.sirsidynix.com/xmlns/common/header',
225            'SdHeader',
226            $data
227        );
228    }
229
230    /**
231     * Return a SymWS session token for given credentials.
232     *
233     * To avoid needing to repeatedly log in the same user,
234     * cache acquired session tokens by the credentials provided.
235     * If the cached session token is expired or otherwise defective,
236     * the caller can use the $reset parameter.
237     *
238     * @param string  $login    The login account name
239     * @param ?string $password The login password, or null for no password
240     * @param bool    $reset    If true, replace any currently cached token
241     *
242     * @return string The session token
243     */
244    protected function getSessionToken(
245        string $login,
246        ?string $password = null,
247        bool $reset = false
248    ) {
249        static $sessionTokens = [];
250
251        // If we keyed only by $login, we might mistakenly retrieve a valid
252        // session token when provided with an invalid password.
253        // We hash the credentials to reduce the potential for
254        // incompatibilities with key limitations of whatever cache backend
255        // an administrator might elect to use for session tokens,
256        // and though more expensive, we use a secure hash because
257        // what we're hashing contains a password.
258        $key = hash('sha256', "$login:$password");
259
260        if (!isset($sessionTokens[$key]) || $reset) {
261            if (!$reset && $token = $_SESSION['symws']['session'][$key]) {
262                $sessionTokens[$key] = $token;
263            } else {
264                $params = ['login' => $login];
265                if (isset($password)) {
266                    $params['password'] = $password;
267                }
268
269                $response = $this->makeRequest('security', 'loginUser', $params);
270                $sessionTokens[$key] = $response->sessionToken;
271                $_SESSION['symws']['session'] = $sessionTokens;
272            }
273        }
274
275        return $sessionTokens[$key];
276    }
277
278    /**
279     * Make a request to Symphony Web Services using the SOAP protocol.
280     *
281     * @param string $service    the SymWS service name
282     * @param string $operation  the SymWS operation name
283     * @param array  $parameters the request parameters for the operation
284     * @param array  $options    An associative array of additional options:
285     * - 'login': login to use for the operation; omit for configured default
286     * credentials or anonymous
287     * - 'password': password associated with login; omit for no password
288     * - 'header': SoapHeader to use for the request; omit to handle automatically
289     *
290     * @return mixed the result of the SOAP call
291     */
292    protected function makeRequest(
293        $service,
294        $operation,
295        $parameters = [],
296        $options = []
297    ) {
298        // If provided, use the SoapHeader and skip the rest of makeRequest().
299        if (isset($options['header'])) {
300            return $this->getSoapClient($service)->soapCall(
301                $operation,
302                $parameters,
303                null,
304                [$options['header']]
305            );
306        }
307
308        /* Determine what credentials, if any, to use for the SymWS request.
309         *
310         * If a login and password are specified in $options, use them.
311         * If not, for any operation not exempted from SymWS'
312         * "Always Require Authentication" option, use the login and password
313         * specified in the configuration. Otherwise, proceed anonymously.
314         */
315        if (isset($options['login'])) {
316            $login    = $options['login'];
317            $password = $options['password'] ?? null;
318        } elseif (
319            isset($options['WebServices']['login'])
320            && !in_array(
321                $operation,
322                ['isRestrictedAccess', 'license', 'loginUser', 'version']
323            )
324        ) {
325            $login    = $this->config['WebServices']['login'];
326            $password = $this->config['WebServices']['password'] ?? null;
327        } else {
328            $login    = null;
329            $password = null;
330        }
331
332        // Attempt the request.
333        $soapClient = $this->getSoapClient($service);
334        try {
335            $header = $this->getSoapHeader($login, $password);
336            $soapClient->__setSoapHeaders($header);
337            return $soapClient->$operation($parameters);
338        } catch (SoapFault $e) {
339            $timeoutException = 'ns0:com.sirsidynix.symws.service.'
340                . 'exceptions.SecurityServiceException.sessionTimedOut';
341            if ($e->faultcode == $timeoutException) {
342                // The SoapHeader's session has expired. Tell
343                // getSoapHeader() to have a new one established.
344                $header = $this->getSoapHeader($login, $password, true);
345                // Try the request again with the new SoapHeader.
346                $soapClient->__setSoapHeaders($header);
347                return $soapClient->$operation($parameters);
348            } elseif ($operation == 'logoutUser') {
349                return null;
350            } elseif ($operation == 'lookupSessionInfo') {
351                // lookupSessionInfo did not exist in SymWS 3.0.
352                $this->checkSymwsVersion();
353                throw $e;
354            } else {
355                throw $e;
356            }
357        }
358    }
359
360    /**
361     * Check the SymWS version, and throw an Exception if it's too old.
362     *
363     * Always checking at initialization would result in many unnecessary
364     * roundtrips with the SymWS server, so this method is intended to be
365     * called when an error happens that might be correctable by upgrading
366     * SymWS. In such a case it will produce a potentially more helpful error
367     * message than the original error would have.
368     *
369     * @throws \Exception if the SymWS version is too old
370     * @return void
371     */
372    protected function checkSymwsVersion()
373    {
374        $resp = $this->makeRequest('standard', 'version', []);
375        foreach ($resp->version as $v) {
376            if ($v->product == 'SYM-WS') {
377                if (version_compare($v->version, 'v3.2', '<')) {
378                    // ILSException didn't seem to produce an error message
379                    // when checkSymwsVersion() was called from the catch
380                    // block in makeRequest().
381                    throw new \Exception('SymWS version too old');
382                }
383                break;
384            }
385        }
386    }
387
388    /**
389     * Get Statuses from 999 Holdings Marc Tag
390     *
391     * Protected support method for parsing status info from the marc record
392     *
393     * @param array $ids The array of record ids to retrieve the item info for
394     *
395     * @return array An associative array of items
396     */
397    protected function getStatuses999Holdings($ids)
398    {
399        $items   = [];
400        $marcMap = [
401            'call number'            => 'marc|a',
402            'copy number'            => 'marc|c',
403            'barcode number'         => 'marc|i',
404            'library'                => 'marc|m',
405            'current location'       => 'marc|k',
406            'home location'          => 'marc|l',
407            'item type'              => 'marc|t',
408            'circulate flag'         => 'marc|r',
409        ];
410
411        $entryNumber = $this->config['999Holdings']['entry_number'];
412
413        $records = $this->recordLoader->loadBatch($ids);
414        foreach ($records as $record) {
415            $results = $record->getFormattedMarcDetails($entryNumber, $marcMap);
416            foreach ($results as $result) {
417                $library  = $this->translatePolicyID('LIBR', $result['library']);
418                $home_loc
419                    = $this->translatePolicyID('LOCN', $result['home location']);
420
421                $curr_loc = isset($result['current location']) ?
422                    $this->translatePolicyID('LOCN', $result['current location']) :
423                    $home_loc;
424
425                $available = (empty($curr_loc) || $curr_loc == $home_loc)
426                    || $result['circulate flag'] == 'Y';
427                $callnumber = $result['call number'];
428                $location   = $library . ' - ' . ($available && !empty($curr_loc)
429                    ? $curr_loc : $home_loc);
430
431                $material = $this->translatePolicyID('ITYP', $result['item type']);
432
433                $items[$result['id']][] = [
434                    'id' => $result['id'],
435                    'availability' => $available,
436                    'status' => $curr_loc,
437                    'location' => $location,
438                    'reserve' => null,
439                    'callnumber' => $callnumber,
440                    'duedate' => null,
441                    'returnDate' => false,
442                    'number' => $result['copy number'],
443                    'barcode' => $result['barcode number'],
444                    'item_id' => $result['barcode number'],
445                    'library' => $library,
446                    'material' => $material,
447                ];
448            }
449        }
450        return $items;
451    }
452
453    /**
454     * Look up title info
455     *
456     * Protected support method for parsing the call info into items.
457     *
458     * @param array $ids The array of record ids to retrieve the item info for
459     *
460     * @return object Result of the "lookupTitleInfo" call to the standard service
461     */
462    protected function lookupTitleInfo($ids)
463    {
464        $ids = is_array($ids) ? $ids : [$ids];
465
466        // SymWS ignores invalid titleIDs instead of rejecting them, so
467        // checking ahead of time for obviously invalid titleIDs is a useful
468        // sanity check (which has a good chance of catching, for example,
469        // the use of something other than catkeys as record IDs).
470        $invalid = preg_grep('/^[1-9][0-9]*$/', $ids, PREG_GREP_INVERT);
471        if (count($invalid) > 0) {
472            $titleIDs = count($invalid) == 1 ? 'titleID' : 'titleIDs';
473            $msg = "Invalid $titleIDs" . implode(', ', $invalid);
474            throw new ILSException($msg);
475        }
476
477        // Prepare $params array for makeRequest().
478        $params = [
479            'titleID' => $ids,
480            'includeAvailabilityInfo' => 'true',
481            'includeItemInfo' => 'true',
482            'includeBoundTogether' => 'true',
483            'includeOrderInfo' => 'true',
484        ];
485
486        // If the driver is configured to populate holdings_text_fields
487        // with MFHD, also request MARC holdings information from SymWS.
488        if (count(array_filter($this->config['MarcHoldings'])) > 0) {
489            $params['includeMarcHoldings'] = 'true';
490            // With neither marcEntryFilter nor marcEntryID, or with
491            // marcEntryFilter NONE, SymWS won't return MarcHoldingsInfo,
492            // and there doesn't seem to be another option for marcEntryFilter
493            // that returns just MarcHoldingsInfo without BibliographicInfo.
494            // So we filter BibliographicInfo for an unlikely entry.
495            $params['marcEntryID'] = '999';
496        }
497
498        // If only one library is being exclusively included,
499        // filtering can be done within Web Services.
500        if (count($this->config['LibraryFilter']['include_only']) == 1) {
501            $params['libraryFilter']
502                = $this->config['LibraryFilter']['include_only'][0];
503        }
504
505        return $this->makeRequest('standard', 'lookupTitleInfo', $params);
506    }
507
508    /**
509     * Determine if a library is excluded by LibraryFilter configuration.
510     *
511     * @param string $libraryID the ID of the library in question
512     *
513     * @return bool             true if excluded, false if not
514     */
515    protected function libraryIsFilteredOut($libraryID)
516    {
517        $notIncluded = !empty($this->config['LibraryFilter']['include_only'])
518            && !in_array(
519                $libraryID,
520                $this->config['LibraryFilter']['include_only']
521            );
522        $excluded = in_array(
523            $libraryID,
524            $this->config['LibraryFilter']['exclude']
525        );
526        return $notIncluded || $excluded;
527    }
528
529    /**
530     * Parse Call Info
531     *
532     * Protected support method for parsing the call info into items.
533     *
534     * @param object $callInfos   The call info of the title
535     * @param int    $titleID     The catalog key of the title in the catalog
536     * @param bool   $is_holdable Whether or not the title is holdable
537     * @param int    $bound_in    The ID of the parent title
538     *
539     * @return array An array of items, an empty array otherwise
540     */
541    protected function parseCallInfo(
542        $callInfos,
543        $titleID,
544        $is_holdable = false,
545        $bound_in = null
546    ) {
547        $items = [];
548
549        $callInfos = is_array($callInfos) ? $callInfos : [$callInfos];
550
551        foreach ($callInfos as $callInfo) {
552            $libraryID = $callInfo->libraryID;
553
554            if ($this->libraryIsFilteredOut($libraryID)) {
555                continue;
556            }
557
558            if (!isset($callInfo->ItemInfo)) {
559                continue; // no items!
560            }
561
562            $library = $this->translatePolicyID('LIBR', $libraryID);
563            // ItemInfo does not include copy numbers, so we generate them under
564            // the assumption that items are being listed in order.
565            $copyNumber = 0;
566
567            $itemInfos = is_array($callInfo->ItemInfo)
568                ? $callInfo->ItemInfo
569                : [$callInfo->ItemInfo];
570            foreach ($itemInfos as $itemInfo) {
571                $in_transit = isset($itemInfo->transitReason);
572                $currentLocation = $this->translatePolicyID(
573                    'LOCN',
574                    $itemInfo->currentLocationID
575                );
576                $homeLocation = $this->translatePolicyID(
577                    'LOCN',
578                    $itemInfo->homeLocationID
579                );
580
581                /* I would like to be able to write
582                 *      $available = $itemInfo->numberOfCharges == 0;
583                 * but SymWS does not appear to provide that information.
584                 *
585                 * SymWS *will* tell me if an item is "chargeable",
586                 * but this is inadequate because reference and internet
587                 * materials may be available, but not chargeable.
588                 *
589                 * I can't rely on the presence of dueDate, because
590                 * although "dueDate is only returned if the item is currently
591                 * checked out", the converse is not true: due dates of NEVER
592                 * are simply omitted.
593                 *
594                 * TitleAvailabilityInfo would be more helpful per item;
595                 * as it is, it tells me only number available and library.
596                 *
597                 * Hence the following criterion: an available item must not
598                 * be in-transit, and if it, like exhibits and reserves,
599                 * is not in its home location, it must be chargeable.
600                 */
601                $available = !$in_transit &&
602                    ($itemInfo->currentLocationID == $itemInfo->homeLocationID
603                    || $itemInfo->chargeable);
604
605                /* Statuses like "Checked out" and "Missing" are represented
606                 * by an item's current location. */
607                $status = $in_transit ? 'In transit' : $currentLocation;
608
609                /* "$library - $location" may be misleading for items that are
610                 * on reserve at a reserve desk in another library, so for
611                 * items on reserve, report location as just the reserve desk.
612                 */
613                if (isset($itemInfo->reserveCollectionID)) {
614                    $reserveDeskID = $itemInfo->reserveCollectionID;
615                    $location = $this->translatePolicyID('RSRV', $reserveDeskID);
616                } else {
617                    /* If an item is available, its current location should be
618                     * reported as its location. */
619                    $location = $available ? $currentLocation : $homeLocation;
620
621                    /* Locations may be shared among libraries, so unless
622                     * holdings are being filtered to just one library,
623                     * it is insufficient to provide just the location
624                     * description as the "location."
625                     */
626                    if (count($this->config['LibraryFilter']['include_only']) != 1) {
627                        $location = "$library - $location";
628                    }
629                }
630
631                $material = $this->translatePolicyID('ITYP', $itemInfo->itemTypeID);
632
633                $duedate = isset($itemInfo->dueDate) ?
634                        date('F j, Y', strtotime($itemInfo->dueDate)) : null;
635                $duedate = isset($itemInfo->recallDueDate) ?
636                        date('F j, Y', strtotime($itemInfo->recallDueDate)) :
637                        $duedate;
638
639                $requests_placed = $itemInfo->numberOfHolds ?? 0;
640
641                // Handle item notes
642                $notes = [];
643
644                if (isset($itemInfo->publicNote)) {
645                    $notes[] = $itemInfo->publicNote;
646                }
647
648                if (
649                    isset($itemInfo->staffNote)
650                    && $this->config['Behaviors']['showStaffNotes']
651                ) {
652                    $notes[] = $itemInfo->staffNote;
653                }
654
655                $transitSourceLibrary
656                    = isset($itemInfo->transitSourceLibraryID)
657                    ? $this->translatePolicyID(
658                        'LIBR',
659                        $itemInfo->transitSourceLibraryID
660                    )
661                    : null;
662
663                $transitDestinationLibrary
664                    = isset($itemInfo->transitDestinationLibraryID)
665                        ? $this->translatePolicyID(
666                            'LIBR',
667                            $itemInfo->transitDestinationLibraryID
668                        )
669                        : null;
670
671                $transitReason = $itemInfo->transitReason ?? null;
672
673                $transitDate = isset($itemInfo->transitDate) ?
674                     date('F j, Y', strtotime($itemInfo->transitDate)) : null;
675
676                $holdtype = $available ? 'hold' : 'recall';
677
678                $items[] = [
679                    'id' => $titleID,
680                    'availability' => $available,
681                    'status' => $status,
682                    'location' => $location,
683                    'reserve' => isset($itemInfo->reserveCollectionID)
684                        ? 'Y' : 'N',
685                    'callnumber' => $callInfo->callNumber,
686                    'duedate' => $duedate,
687                    'returnDate' => false, // Not returned by symws
688                    'number' => ++$copyNumber,
689                    'requests_placed' => $requests_placed,
690                    'barcode' => $itemInfo->itemID,
691                    'notes' => $notes,
692                    'summary' => [],
693                    'is_holdable' => $is_holdable,
694                    'holdtype' => $holdtype,
695                    'addLink' => $is_holdable,
696                    'item_id' => $itemInfo->itemID,
697
698                    // The fields below are non-standard and
699                    // should be added to your holdings.tpl
700                    // RecordDriver template to be utilized.
701                    'library' => $library,
702                    'material' => $material,
703                    'bound_in' => $bound_in,
704                    //'bound_in_title' => ,
705                    'transit_source_library' =>
706                        $transitSourceLibrary,
707                    'transit_destination_library' =>
708                        $transitDestinationLibrary,
709                    'transit_reason' => $transitReason,
710                    'transit_date' => $transitDate,
711                ];
712            }
713        }
714        return $items;
715    }
716
717    /**
718     * Parse Bound With Link Info
719     *
720     * Protected support method for parsing bound with link information.
721     *
722     * @param object $boundwithLinkInfos The boundwithLinkInfos object of the title
723     * @param int    $ckey               The catalog key of the title in the catalog
724     *
725     * @return array An array of parseCallInfo() return values on success,
726     * an empty array otherwise.
727     */
728    protected function parseBoundwithLinkInfo($boundwithLinkInfos, $ckey)
729    {
730        $items = [];
731
732        $boundwithLinkInfos = is_array($boundwithLinkInfos)
733            ? $boundwithLinkInfos
734            : [$boundwithLinkInfos];
735
736        foreach ($boundwithLinkInfos as $boundwithLinkInfo) {
737            // Ignore BoundwithLinkInfos which do not refer to parents
738            // or which refer to the record we're already looking at.
739            if (
740                !$boundwithLinkInfo->linkedAsParent
741                || $boundwithLinkInfo->linkedTitle->titleID == $ckey
742            ) {
743                continue;
744            }
745
746            // Fetch the record that contains the parent CallInfo,
747            // identify the CallInfo by matching itemIDs,
748            // and parse that CallInfo in the items array.
749            $parent_ckey   = $boundwithLinkInfo->linkedTitle->titleID;
750            $linked_itemID = $boundwithLinkInfo->itemID;
751            $resp          = $this->lookupTitleInfo($parent_ckey);
752            $is_holdable   = $resp->TitleInfo->TitleAvailabilityInfo->holdable;
753
754            $callInfos = is_array($resp->TitleInfo->CallInfo)
755                ? $resp->TitleInfo->CallInfo
756                : [$resp->TitleInfo->CallInfo];
757
758            foreach ($callInfos as $callInfo) {
759                $itemInfos = is_array($callInfo->ItemInfo)
760                    ? $callInfo->ItemInfo
761                    : [$callInfo->ItemInfo];
762                foreach ($itemInfos as $itemInfo) {
763                    if ($itemInfo->itemID == $linked_itemID) {
764                        $items += $this->parseCallInfo(
765                            $callInfo,
766                            $ckey,
767                            $is_holdable,
768                            $parent_ckey
769                        );
770                    }
771                }
772            }
773        }
774
775        return $items;
776    }
777
778    /**
779     * Parse Title Order Info
780     *
781     * Protected support method for parsing order info.
782     *
783     * @param object $titleOrderInfos The titleOrderInfo object of the title
784     * @param int    $titleID         The ID of the title in the catalog
785     *
786     * @return array An array of items that are on order, an empty array otherwise.
787     */
788    protected function parseTitleOrderInfo($titleOrderInfos, $titleID)
789    {
790        $items = [];
791
792        $titleOrderInfos = is_array($titleOrderInfos)
793            ? $titleOrderInfos : [$titleOrderInfos];
794
795        foreach ($titleOrderInfos as $titleOrderInfo) {
796            $library_id = $titleOrderInfo->orderLibraryID;
797
798            /* Allow returned holdings information to be
799             * limited to a specified list of library names. */
800            if (
801                isset($this->config['holdings']['include_libraries'])
802                && !in_array(
803                    $library_id,
804                    $this->config['holdings']['include_libraries']
805                )
806            ) {
807                continue;
808            }
809
810            /* Allow libraries to be excluded by name
811             * from returned holdings information. */
812            if (
813                isset($this->config['holdings']['exclude_libraries'])
814                && in_array(
815                    $library_id,
816                    $this->config['holdings']['exclude_libraries']
817                )
818            ) {
819                continue;
820            }
821
822            $nr_copies = $titleOrderInfo->copiesOrdered;
823            $library   = $this->translatePolicyID('LIBR', $library_id);
824
825            $statuses = [];
826            if (!empty($titleOrderInfo->orderDateReceived)) {
827                $statuses[] = "Received $titleOrderInfo->orderDateReceived";
828            }
829
830            if (!empty($titleOrderInfo->orderNote)) {
831                $statuses[] = $titleOrderInfo->orderNote;
832            }
833
834            if (!empty($titleOrderInfo->volumesOrdered)) {
835                $statuses[] = $titleOrderInfo->volumesOrdered;
836            }
837
838            for ($i = 1; $i <= $nr_copies; ++$i) {
839                $items[] = [
840                    'id' => $titleID,
841                    'availability' => false,
842                    'status' => implode('; ', $statuses),
843                    'location' => "On order for $library",
844                    'callnumber' => null,
845                    'duedate' => null,
846                    'reserve' => 'N',
847                    'number' => $i,
848                    'barcode' => true,
849                    'offsite' => $library_id == 'OFFSITE',
850                ];
851            }
852        }
853        return $items;
854    }
855
856    /**
857     * Parse MarcHoldingInfo into VuFind items.
858     *
859     * @param object $marcHoldingsInfos MarcHoldingInfo, from TitleInfo
860     * @param int    $titleID           The catalog key of the title record
861     *
862     * @return array  an array (possibly empty) of VuFind items
863     */
864    protected function parseMarcHoldingsInfo($marcHoldingsInfos, $titleID)
865    {
866        $items = [];
867        $marcHoldingsInfos = is_array($marcHoldingsInfos)
868            ? $marcHoldingsInfos
869            : [$marcHoldingsInfos];
870
871        foreach ($marcHoldingsInfos as $marcHoldingsInfo) {
872            $libraryID = $marcHoldingsInfo->holdingLibraryID;
873            if ($this->libraryIsFilteredOut($libraryID)) {
874                continue;
875            }
876
877            $marcEntryInfos = is_array($marcHoldingsInfo->MarcEntryInfo)
878                ? $marcHoldingsInfo->MarcEntryInfo
879                : [$marcHoldingsInfo->MarcEntryInfo];
880            $item = [];
881
882            foreach ($marcEntryInfos as $marcEntryInfo) {
883                foreach ($this->config['MarcHoldings'] as $textfield => $spec) {
884                    if (in_array($marcEntryInfo->entryID, $spec)) {
885                        $item[$textfield][] = $marcEntryInfo->text;
886                    }
887                }
888            }
889
890            if (!empty($item)) {
891                $items[] = $item + [
892                    'id' => $titleID,
893                    'location' => $this->translatePolicyID('LIBR', $libraryID),
894                ];
895            }
896        }
897
898        return $items;
899    }
900
901    /**
902     * Get Live Statuses
903     *
904     * Protected support method for retrieving a list of item statuses from symws.
905     *
906     * @param array $ids The array of record ids to retrieve the status for
907     *
908     * @return array An array of parseCallInfo() return values on success,
909     * an empty array otherwise.
910     */
911    protected function getLiveStatuses($ids)
912    {
913        $items = [];
914        foreach ($ids as $id) {
915            $items[$id] = [];
916        }
917
918        /* In Symphony, a title record has at least one "callnum" record,
919         * to which are attached zero or more item records. This structure
920         * is reflected in the LookupTitleInfoResponse, which contains
921         * one or more TitleInfo elements, which contain one or more
922         * CallInfo elements, which contain zero or more ItemInfo elements.
923         */
924        $response   = $this->lookupTitleInfo($ids);
925        $titleInfos = is_array($response->TitleInfo)
926            ? $response->TitleInfo
927            : [$response->TitleInfo];
928
929        foreach ($titleInfos as $titleInfo) {
930            $ckey        = $titleInfo->titleID;
931            $is_holdable = $titleInfo->TitleAvailabilityInfo->holdable;
932
933            /* In order to have only one item record per item regardless of
934             * how many titles are bound within, Symphony handles titles bound
935             * with others by linking callnum records in parent-children
936             * relationships, where only the parent callnum has item records
937             * attached to it. The CallInfo element of a child callnum
938             * does not contain any ItemInfo elements, so we must locate the
939             * parent CallInfo using BoundwithLinkInfo, in order to parse
940             * the ItemInfo.
941             */
942            if (isset($titleInfo->BoundwithLinkInfo)) {
943                $items[$ckey] = $this->parseBoundwithLinkInfo(
944                    $titleInfo->BoundwithLinkInfo,
945                    $ckey
946                );
947            }
948
949            /* Callnums that are not bound-with, or are bound-with parents,
950             * have item records and can be parsed directly. Since bound-with
951             * children do not have item records, parsing them should have no
952             * effect. */
953            if (isset($titleInfo->CallInfo)) {
954                $items[$ckey] = array_merge(
955                    $items[$ckey],
956                    $this->parseCallInfo($titleInfo->CallInfo, $ckey, $is_holdable)
957                );
958            }
959
960            /* Copies on order do not have item records,
961             * so we make some pseudo-items for VuFind. */
962            if (isset($titleInfo->TitleOrderInfo)) {
963                $items[$ckey] = array_merge(
964                    $items[$ckey],
965                    $this->parseTitleOrderInfo($titleInfo->TitleOrderInfo, $ckey)
966                );
967            }
968
969            /* MARC holdings records are associated with title records rather
970             * than item records, so we make pseudo-items for VuFind. */
971            if (isset($titleInfo->MarcHoldingsInfo)) {
972                $items[$ckey] = array_merge(
973                    $items[$ckey],
974                    $this->parseMarcHoldingsInfo($titleInfo->MarcHoldingsInfo, $ckey)
975                );
976            }
977        }
978        return $items;
979    }
980
981    /**
982     * Translate a Symphony policy ID into a policy description
983     * (e.g. VIDEO-COLL => Videorecording Collection).
984     *
985     * In order to minimize roundtrips with the SymWS server,
986     * we fetch more than was requested and cache the results.
987     * At time of writing, SymWS did not appear to
988     * support retrieving policies of multiple types simultaneously,
989     * so we currently fetch only all policies of one type at a time.
990     *
991     * @param string $policyType The policy type, e.g. LOCN or LIBR.
992     * @param string $policyID   The policy ID, e.g. VIDEO-COLL or SWEM.
993     *
994     * @return string The policy description, if found, or the policy ID, if not.
995     *
996     * @todo policy description override
997     */
998    protected function translatePolicyID($policyType, $policyID)
999    {
1000        $policyType = strtoupper($policyType);
1001        $policyID   = strtoupper($policyID);
1002        $policyList = $this->getPolicyList($policyType);
1003
1004        return $policyList[$policyID] ?? $policyID;
1005    }
1006
1007    /**
1008     * Get Status
1009     *
1010     * This is responsible for retrieving the status information of a certain
1011     * record.
1012     *
1013     * @param string $id The record id to retrieve the holdings for
1014     *
1015     * @throws ILSException
1016     * @return mixed     On success, an associative array with the following keys:
1017     * id, availability (boolean), status, location, reserve, callnumber.
1018     */
1019    public function getStatus($id)
1020    {
1021        $statuses = $this->getStatuses([$id]);
1022        return $statuses[$id] ?? [];
1023    }
1024
1025    /**
1026     * Get Statuses
1027     *
1028     * This is responsible for retrieving the status information for a
1029     * collection of records.
1030     *
1031     * @param array $ids The array of record ids to retrieve the status for
1032     *
1033     * @throws ILSException
1034     * @return array     An array of getStatus() return values on success.
1035     */
1036    public function getStatuses($ids)
1037    {
1038        if ($this->config['999Holdings']['mode']) {
1039            return $this->getStatuses999Holdings($ids);
1040        } else {
1041            return $this->getLiveStatuses($ids);
1042        }
1043    }
1044
1045    /**
1046     * Get Holding
1047     *
1048     * This is responsible for retrieving the holding information of a certain
1049     * record.
1050     *
1051     * @param string $id      The record id to retrieve the holdings for
1052     * @param array  $patron  Patron data
1053     * @param array  $options Extra options (not currently used)
1054     *
1055     * @throws ILSException
1056     * @return array         On success, an associative array with the following
1057     * keys: id, availability (boolean), status, location, reserve, callnumber,
1058     * duedate, number, barcode.
1059     *
1060     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1061     */
1062    public function getHolding($id, array $patron = null, array $options = [])
1063    {
1064        return $this->getStatus($id);
1065    }
1066
1067    /**
1068     * Get Purchase History
1069     *
1070     * This is responsible for retrieving the acquisitions history data for the
1071     * specific record (usually recently received issues of a serial).
1072     *
1073     * @param string $id The record id to retrieve the info for
1074     *
1075     * @throws ILSException
1076     * @return array     An array with the acquisitions data on success.
1077     */
1078    public function getPurchaseHistory($id)
1079    {
1080        return [];
1081    }
1082
1083    /**
1084     * Patron Login
1085     *
1086     * This is responsible for authenticating a patron against the catalog.
1087     *
1088     * @param string $username The patron username
1089     * @param string $password The patron password
1090     *
1091     * @throws ILSException
1092     * @return mixed          Associative array of patron info on successful login,
1093     * null on unsuccessful login.
1094     */
1095    public function patronLogin($username, $password)
1096    {
1097        $usernameField = $this->config['Behaviors']['usernameField'];
1098
1099        $patron = [
1100            'cat_username' => $username,
1101            'cat_password' => $password,
1102        ];
1103
1104        try {
1105            $resp = $this->makeRequest(
1106                'patron',
1107                'lookupMyAccountInfo',
1108                [
1109                    'includePatronInfo' => 'true',
1110                    'includePatronAddressInfo' => 'true',
1111                ],
1112                [
1113                    'login' => $username,
1114                    'password' => $password,
1115                ]
1116            );
1117        } catch (SoapFault $e) {
1118            $unableToLogin = 'ns0:com.sirsidynix.symws.service.'
1119                . 'exceptions.SecurityServiceException.unableToLogin';
1120            if ($e->faultcode == $unableToLogin) {
1121                return null;
1122            } else {
1123                throw $e;
1124            }
1125        }
1126
1127        $patron['id']      = $resp->patronInfo->$usernameField;
1128        $patron['library'] = $resp->patronInfo->patronLibraryID;
1129
1130        $regEx = '/([^,]*),\s([^\s]*)/';
1131        if (preg_match($regEx, $resp->patronInfo->displayName, $matches)) {
1132            $patron['firstname'] = $matches[2];
1133            $patron['lastname']  = $matches[1];
1134        }
1135
1136        // There may be an email address in any of three numbered addresses,
1137        // so we search each one until we find an email address,
1138        // starting with the one marked primary.
1139        $addrinfo_check_order = ['1','2','3'];
1140        if (isset($resp->patronAddressInfo->primaryAddress)) {
1141            $primary_addr_n = $resp->patronAddressInfo->primaryAddress;
1142            array_unshift($addrinfo_check_order, $primary_addr_n);
1143        }
1144        foreach ($addrinfo_check_order as $n) {
1145            $AddressNInfo = "Address{$n}Info";
1146            if (isset($resp->patronAddressInfo->$AddressNInfo)) {
1147                $addrinfos = is_array($resp->patronAddressInfo->$AddressNInfo)
1148                    ? $resp->patronAddressInfo->$AddressNInfo
1149                    : [$resp->patronAddressInfo->$AddressNInfo];
1150                foreach ($addrinfos as $addrinfo) {
1151                    if (
1152                        $addrinfo->addressPolicyID == 'EMAIL'
1153                        && !empty($addrinfo->addressValue)
1154                    ) {
1155                        $patron['email'] = $addrinfo->addressValue;
1156                        break;
1157                    }
1158                }
1159            }
1160        }
1161
1162        // @TODO: major, college
1163
1164        return $patron;
1165    }
1166
1167    /**
1168     * Get Patron Profile
1169     *
1170     * This is responsible for retrieving the profile for a specific patron.
1171     *
1172     * @param array $patron The patron array
1173     *
1174     * @throws ILSException
1175     * @return array        Array of the patron's profile data on success.
1176     */
1177    public function getMyProfile($patron)
1178    {
1179        try {
1180            $userProfileGroupField
1181                = $this->config['Behaviors']['userProfileGroupField'];
1182
1183            $options = [
1184                'includePatronInfo' => 'true',
1185                'includePatronAddressInfo' => 'true',
1186                'includePatronStatusInfo' => 'true',
1187                'includeUserGroupInfo'     => 'true',
1188            ];
1189
1190            $result = $this->makeRequest(
1191                'patron',
1192                'lookupMyAccountInfo',
1193                $options,
1194                [
1195                    'login' => $patron['cat_username'],
1196                    'password' => $patron['cat_password'],
1197                ]
1198            );
1199
1200            $primaryAddress = $result->patronAddressInfo->primaryAddress;
1201
1202            $primaryAddressInfo = 'Address' . $primaryAddress . 'Info';
1203
1204            $addressInfo = $result->patronAddressInfo->$primaryAddressInfo;
1205            $address1    = $addressInfo[0]->addressValue;
1206            $address2    = $addressInfo[1]->addressValue;
1207            $zip         = $addressInfo[2]->addressValue;
1208            $phone       = $addressInfo[3]->addressValue;
1209
1210            if (strcmp($userProfileGroupField, 'GROUP_ID') == 0) {
1211                $group = $result->patronInfo->groupID;
1212            } elseif (strcmp($userProfileGroupField, 'USER_PROFILE_ID') == 0) {
1213                $group = $this->makeRequest(
1214                    'security',
1215                    'lookupSessionInfo',
1216                    $options,
1217                    [
1218                        'login' => $patron['cat_username'],
1219                        'password' => $patron['cat_password'],
1220                    ]
1221                )->userProfileID;
1222            } elseif (strcmp($userProfileGroupField, 'PATRON_LIBRARY_ID') == 0) {
1223                $group = $result->patronInfo->patronLibraryID;
1224            } elseif (strcmp($userProfileGroupField, 'DEPARTMENT') == 0) {
1225                $group = $result->patronInfo->department;
1226            } else {
1227                $group = null;
1228            }
1229
1230            [$lastname, $firstname]
1231                = explode(', ', $result->patronInfo->displayName);
1232
1233            $profile = [
1234                'lastname' => $lastname,
1235                'firstname' => $firstname,
1236                'address1' => $address1,
1237                'address2' => $address2,
1238                'zip' => $zip,
1239                'phone' => $phone,
1240                'group' => $group,
1241            ];
1242        } catch (\Exception $e) {
1243            $this->throwAsIlsException($e);
1244        }
1245        return $profile;
1246    }
1247
1248    /**
1249     * Get Patron Transactions
1250     *
1251     * This is responsible for retrieving all transactions (i.e. checked out items)
1252     * by a specific patron.
1253     *
1254     * @param array $patron The patron array from patronLogin
1255     *
1256     * @throws ILSException
1257     * @return array        Array of the patron's transactions on success.
1258     */
1259    public function getMyTransactions($patron)
1260    {
1261        try {
1262            $transList = [];
1263            $options   = ['includePatronCheckoutInfo' => 'ALL'];
1264
1265            $result = $this->makeRequest(
1266                'patron',
1267                'lookupMyAccountInfo',
1268                $options,
1269                [
1270                    'login' => $patron['cat_username'],
1271                    'password' => $patron['cat_password'],
1272                ]
1273            );
1274
1275            if (isset($result->patronCheckoutInfo)) {
1276                $transactions = $result->patronCheckoutInfo;
1277                $transactions = !is_array($transactions) ? [$transactions] :
1278                    $transactions;
1279
1280                foreach ($transactions as $transaction) {
1281                    $urr = !empty($transaction->unseenRenewalsRemaining)
1282                        || !empty($transaction->unseenRenewalsRemainingUnlimited);
1283                    $rr = !empty($transaction->renewalsRemaining)
1284                        || !empty($transaction->renewalsRemainingUnlimited);
1285                    $renewable = ($urr && $rr);
1286
1287                    $transList[] = [
1288                        'duedate' =>
1289                            date('F j, Y', strtotime($transaction->dueDate)),
1290                        'id' => $transaction->titleKey,
1291                        'barcode' => $transaction->itemID,
1292                        'renew' => $transaction->renewals,
1293                        'request' => $transaction->recallNoticesSent,
1294                        //'volume' => null,
1295                        //'publication_year' => null,
1296                        'renewable' => $renewable,
1297                        //'message' => null,
1298                        'title' => $transaction->title,
1299                        'item_id' => $transaction->itemID,
1300                    ];
1301                }
1302            }
1303        } catch (\Exception $e) {
1304            $this->throwAsIlsException($e);
1305        }
1306        return $transList;
1307    }
1308
1309    /**
1310     * Get Patron Holds
1311     *
1312     * This is responsible for retrieving all holds by a specific patron.
1313     *
1314     * @param array $patron The patron array from patronLogin
1315     *
1316     * @throws ILSException
1317     * @return array        Array of the patron's holds on success.
1318     */
1319    public function getMyHolds($patron)
1320    {
1321        try {
1322            $holdList = [];
1323            $options  = ['includePatronHoldInfo' => 'ACTIVE'];
1324
1325            $result = $this->makeRequest(
1326                'patron',
1327                'lookupMyAccountInfo',
1328                $options,
1329                [
1330                    'login' => $patron['cat_username'],
1331                    'password' => $patron['cat_password'],
1332                ]
1333            );
1334
1335            if (!property_exists($result, 'patronHoldInfo')) {
1336                return null;
1337            }
1338
1339            $holds = $result->patronHoldInfo;
1340            $holds = !is_array($holds) ? [$holds] : $holds;
1341
1342            foreach ($holds as $hold) {
1343                $holdList[] = [
1344                    'id' => $hold->titleKey,
1345                    //'type' => ,
1346                    'location' => $hold->pickupLibraryID,
1347                    'reqnum' => $hold->holdKey,
1348                    'expire' => date('F j, Y', strtotime($hold->expiresDate)),
1349                    'create' => date('F j, Y', strtotime($hold->placedDate)),
1350                    'position' => $hold->queuePosition,
1351                    'available' => $hold->available,
1352                    'item_id' => $hold->itemID,
1353                    //'volume' => null,
1354                    //'publication_year' => null,
1355                    'title' => $hold->title,
1356                ];
1357            }
1358        } catch (SoapFault $e) {
1359            return null;
1360        } catch (\Exception $e) {
1361            $this->throwAsIlsException($e);
1362        }
1363        return $holdList;
1364    }
1365
1366    /**
1367     * Get Patron Fines
1368     *
1369     * This is responsible for retrieving all fines by a specific patron.
1370     *
1371     * @param array $patron The patron array from patronLogin
1372     *
1373     * @throws ILSException
1374     * @return mixed        Array of the patron's fines on success.
1375     */
1376    public function getMyFines($patron)
1377    {
1378        try {
1379            $fineList = [];
1380            $feeType  = $this->config['Behaviors']['showFeeType'];
1381            $options  = ['includeFeeInfo' => $feeType];
1382
1383            $result = $this->makeRequest(
1384                'patron',
1385                'lookupMyAccountInfo',
1386                $options,
1387                [
1388                    'login' => $patron['cat_username'],
1389                    'password' => $patron['cat_password'],
1390                ]
1391            );
1392
1393            if (isset($result->feeInfo)) {
1394                $fees = $result->feeInfo;
1395                $fees = !is_array($fees) ? [$fees] : $fees;
1396
1397                foreach ($fees as $fee) {
1398                    $fineList[] = [
1399                        'amount' => $fee->amount->_ * 100,
1400                        'checkout' => $fee->feeItemInfo->checkoutDate ?? null,
1401                        'fine' => $fee->billReasonDescription,
1402                        'balance' => $fee->amountOutstanding->_ * 100,
1403                        'createdate' => $fee->dateBilled ?? null,
1404                        'duedate' => $fee->feeItemInfo->dueDate ?? null,
1405                        'id' => $fee->feeItemInfo->titleKey ?? null,
1406                    ];
1407                }
1408            }
1409
1410            return $fineList;
1411        } catch (SoapFault | \Exception $e) {
1412            $this->throwAsIlsException($e);
1413        }
1414    }
1415
1416    /**
1417     * Get Cancel Hold Form
1418     *
1419     * Supplies the form details required to cancel a hold
1420     *
1421     * @param array $holdDetails A single hold array from getMyHolds
1422     * @param array $patron      Patron information from patronLogin
1423     *
1424     * @return string  Data for use in a form field
1425     *
1426     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1427     */
1428    public function getCancelHoldDetails($holdDetails, $patron = [])
1429    {
1430        return $holdDetails['reqnum'];
1431    }
1432
1433    /**
1434     * Cancel Holds
1435     *
1436     * Attempts to Cancel a hold on a particular item
1437     *
1438     * @param array $cancelDetails An array of item and patron data
1439     *
1440     * @return mixed  An array of data on each request including
1441     * whether or not it was successful and a system message (if available)
1442     * or boolean false on failure
1443     */
1444    public function cancelHolds($cancelDetails)
1445    {
1446        $count  = 0;
1447        $items  = [];
1448        $patron = $cancelDetails['patron'];
1449
1450        foreach ($cancelDetails['details'] as $holdKey) {
1451            try {
1452                $options = ['holdKey' => $holdKey];
1453
1454                $this->makeRequest(
1455                    'patron',
1456                    'cancelMyHold',
1457                    $options,
1458                    [
1459                        'login' => $patron['cat_username'],
1460                        'password' => $patron['cat_password'],
1461                    ]
1462                );
1463
1464                $count++;
1465                $items[$holdKey] = [
1466                    'success' => true,
1467                    'status' => 'hold_cancel_success',
1468                ];
1469            } catch (\Exception $e) {
1470                $items[$holdKey] = [
1471                    'success' => false,
1472                    'status' => 'hold_cancel_fail',
1473                    'sysMessage' => $e->getMessage(),
1474                ];
1475            }
1476        }
1477        $result = ['count' => $count, 'items' => $items];
1478        return $result;
1479    }
1480
1481    /**
1482     * Public Function which retrieves renew, hold and cancel settings from the
1483     * driver ini file.
1484     *
1485     * @param string $function The name of the feature to be checked
1486     * @param array  $params   Optional feature-specific parameters (array)
1487     *
1488     * @return array An array with key-value pairs.
1489     *
1490     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1491     */
1492    public function getConfig($function, $params = [])
1493    {
1494        if (isset($this->config[$function])) {
1495            $functionConfig = $this->config[$function];
1496        } else {
1497            $functionConfig = false;
1498        }
1499        return $functionConfig;
1500    }
1501
1502    /**
1503     * Get Renew Details
1504     *
1505     * In order to renew an item, Symphony requires the patron details and an item
1506     * id. This function returns the item id as a string which is then used
1507     * as submitted form data in checkedOut.php. This value is then extracted by
1508     * the RenewMyItems function.
1509     *
1510     * @param array $checkOutDetails An array of item data
1511     *
1512     * @return string Data for use in a form field
1513     */
1514    public function getRenewDetails($checkOutDetails)
1515    {
1516        $renewDetails = $checkOutDetails['barcode'];
1517        return $renewDetails;
1518    }
1519
1520    /**
1521     * Renew My Items
1522     *
1523     * Function for attempting to renew a patron's items. The data in
1524     * $renewDetails['details'] is determined by getRenewDetails().
1525     *
1526     * @param array $renewDetails An array of data required for renewing items
1527     * including the Patron ID and an array of renewal IDS
1528     *
1529     * @return array              An array of renewal information keyed by item ID
1530     */
1531    public function renewMyItems($renewDetails)
1532    {
1533        $details = [];
1534        $patron  = $renewDetails['patron'];
1535
1536        foreach ($renewDetails['details'] as $barcode) {
1537            try {
1538                $options = ['itemID' => $barcode];
1539
1540                $renewal = $this->makeRequest(
1541                    'patron',
1542                    'renewMyCheckout',
1543                    $options,
1544                    [
1545                        'login' => $patron['cat_username'],
1546                        'password' => $patron['cat_password'],
1547                    ]
1548                );
1549
1550                $details[$barcode] = [
1551                    'success' => true,
1552                    'new_date' => date('j-M-y', strtotime($renewal->dueDate)),
1553                    'new_time' => date('g:i a', strtotime($renewal->dueDate)),
1554                    'item_id' => $renewal->itemID,
1555                    'sysMessage' => $renewal->message,
1556                ];
1557            } catch (\Exception $e) {
1558                $details[$barcode] = [
1559                    'success' => false,
1560                    'new_date' => false,
1561                    'new_time' => false,
1562                    'sysMessage' =>
1563                        'We could not renew this item: ' . $e->getMessage(),
1564                ];
1565            }
1566        }
1567
1568        $result = ['details' => $details];
1569        return $result;
1570    }
1571
1572    /**
1573     * Place Hold
1574     *
1575     * Attempts to place a hold or recall on a particular item
1576     *
1577     * @param array $holdDetails An array of item and patron data
1578     *
1579     * @return array  An array of data on the request including
1580     * whether or not it was successful and a system message (if available)
1581     */
1582    public function placeHold($holdDetails)
1583    {
1584        try {
1585            $options = [];
1586            $patron  = $holdDetails['patron'];
1587
1588            if ($holdDetails['item_id'] != null) {
1589                $options['itemID'] = $holdDetails['item_id'];
1590            }
1591
1592            if ($holdDetails['id'] != null) {
1593                $options['titleKey'] = $holdDetails['id'];
1594            }
1595
1596            if ($holdDetails['pickUpLocation'] != null) {
1597                $options['pickupLibraryID'] = $holdDetails['pickUpLocation'];
1598            }
1599
1600            if ($holdDetails['requiredBy'] != null) {
1601                $options['expiresDate'] = $holdDetails['requiredBy'];
1602            }
1603
1604            if ($holdDetails['comment'] != null) {
1605                $options['comment'] = $holdDetails['comment'];
1606            }
1607
1608            $this->makeRequest(
1609                'patron',
1610                'createMyHold',
1611                $options,
1612                [
1613                    'login' => $patron['cat_username'],
1614                    'password' => $patron['cat_password'],
1615                ]
1616            );
1617
1618            $result = [
1619                'success' => true,
1620                'sysMessage' => 'Your hold has been placed.',
1621            ];
1622            return $result;
1623        } catch (SoapFault $e) {
1624            $result = [
1625                'success' => false,
1626                'sysMessage' =>
1627                    'We could not place the hold: ' . $e->getMessage(),
1628            ];
1629            return $result;
1630        }
1631    }
1632
1633    /**
1634     * Get Policy List
1635     *
1636     * Protected support method for getting a list of policies.
1637     *
1638     * @param string $policyType Symphony policy code for type of policy
1639     *
1640     * @return array An associative array of policy codes and descriptions.
1641     */
1642    protected function getPolicyList($policyType)
1643    {
1644        try {
1645            $cacheKey = 'symphony' . hash('sha256', "{$policyType}");
1646
1647            if (isset($this->policies[$policyType])) {
1648                return $this->policies[$policyType];
1649            } elseif (
1650                $this->policyCache
1651                && ($policyList = $this->policyCache->getItem($cacheKey))
1652            ) {
1653                $this->policies[$policyType] = $policyList;
1654                return $policyList;
1655            } else {
1656                $policyList = [];
1657                $options    = ['policyType' => $policyType];
1658                $policies   = $this->makeRequest(
1659                    'admin',
1660                    'lookupPolicyList',
1661                    $options
1662                );
1663
1664                foreach ($policies->policyInfo as $policyInfo) {
1665                    $policyList[$policyInfo->policyID]
1666                        = $policyInfo->policyDescription;
1667                }
1668
1669                if ($this->policyCache) {
1670                    $this->policyCache->setItem($cacheKey, $policyList);
1671                }
1672
1673                return $policyList;
1674            }
1675        } catch (\Exception $e) {
1676            return [];
1677        }
1678    }
1679
1680    /**
1681     * Get Pick Up Locations
1682     *
1683     * This is responsible get a list of valid library locations for holds / recall
1684     * retrieval
1685     *
1686     * @param array $patron      Patron information returned by the patronLogin
1687     * method.
1688     * @param array $holdDetails Optional array, only passed in when getting a list
1689     * in the context of placing or editing a hold. When placing a hold, it contains
1690     * most of the same values passed to placeHold, minus the patron data. When
1691     * editing a hold it contains all the hold information returned by getMyHolds.
1692     * May be used to limit the pickup options or may be ignored. The driver must
1693     * not add new options to the return array based on this data or other areas of
1694     * VuFind may behave incorrectly.
1695     *
1696     * @return array        An array of associative arrays with locationID and
1697     * locationDisplay keys
1698     *
1699     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1700     */
1701    public function getPickUpLocations($patron = false, $holdDetails = null)
1702    {
1703        $libraries = [];
1704
1705        foreach ($this->getPolicyList('LIBR') as $key => $library) {
1706            $libraries[] = [
1707                'locationID' => $key,
1708                'locationDisplay' => $library,
1709            ];
1710        }
1711
1712        return $libraries;
1713    }
1714
1715    /**
1716     * Get Default Pick Up Location
1717     *
1718     * Returns the default pick up location set in Symphony.ini
1719     *
1720     * @param array $patron      Patron information returned by the patronLogin
1721     * method.
1722     * @param array $holdDetails Optional array, only passed in when getting a list
1723     * in the context of placing a hold; contains most of the same values passed to
1724     * placeHold, minus the patron data. May be used to limit the pickup options
1725     * or may be ignored.
1726     *
1727     * @return string       The default pickup location for the patron.
1728     *
1729     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1730     */
1731    public function getDefaultPickUpLocation($patron = false, $holdDetails = null)
1732    {
1733        if (isset($patron['library'])) {
1734            // Check for library in patron info
1735            return $patron['library'];
1736        } elseif (isset($this->config['Holds']['defaultPickUpLocation'])) {
1737            // If no library returned in patron info, check config file
1738            return $this->config['Holds']['defaultPickUpLocation'];
1739        } else {
1740            // Default to first library in the list if none specified
1741            // in patron info or config file
1742            $libraries = $this->getPickUpLocations();
1743            return $libraries[0]['locationID'];
1744        }
1745    }
1746}