Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.23% covered (danger)
0.23%
2 / 877
0.00% covered (danger)
0.00%
0 / 32
CRAP
0.00% covered (danger)
0.00%
0 / 1
Virtua
0.23% covered (danger)
0.23%
2 / 877
0.00% covered (danger)
0.00%
0 / 32
39529.69
0.00% covered (danger)
0.00%
0 / 1
 init
10.53% covered (danger)
10.53%
2 / 19
0.00% covered (danger)
0.00%
0 / 1
4.87
 getStatus
0.00% covered (danger)
0.00%
0 / 140
0.00% covered (danger)
0.00%
0 / 1
1482
 getStatuses
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getHolding
0.00% covered (danger)
0.00%
0 / 76
0.00% covered (danger)
0.00%
0 / 1
552
 checkHoldAllowed
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
272
 getField
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 renderPartSubPattern
0.00% covered (danger)
0.00%
0 / 111
0.00% covered (danger)
0.00%
0 / 1
600
 renderSubPattern
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 renderOtherPattern
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 renderPattern
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 renderSerialHoldings
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
110
 getPurchaseHistory
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 getAll853
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 patronLogin
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
20
 getMyProfile
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
30
 getMyFines
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 getMyHolds
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 getMyTransactions
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 getCourses
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 findReserves
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 getOpeningHours
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
30
 placeHold
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
42
 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 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 cancelHold
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 fakeLogin
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
2
 getRenewDetails
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 renewMyItems
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
30
 getSuppressedAuthorityRecords
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getApiBaseUrl
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getConfiguredLanguage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 httpRequest
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
56
1<?php
2
3/**
4 * VTLS Virtua Driver
5 *
6 * PHP version 8
7 *
8 * Copyright (C) University of Southern Queensland 2008.
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   Greg Pendlebury <vufind-tech@lists.sourceforge.net>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
28 */
29
30namespace VuFind\ILS\Driver;
31
32use VuFind\Date\DateException;
33use VuFind\Exception\ILS as ILSException;
34
35use function count;
36use function in_array;
37use function is_array;
38use function strlen;
39
40/**
41 * VTLS Virtua Driver
42 *
43 * @category VuFind
44 * @package  ILS_Drivers
45 * @author   Greg Pendlebury <vufind-tech@lists.sourceforge.net>
46 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
47 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
48 */
49class Virtua extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
50{
51    use \VuFindHttp\HttpServiceAwareTrait;
52
53    /**
54     * Oracle connection
55     *
56     * @var \VuFind\Connection\Oracle
57     */
58    protected $db;
59
60    /**
61     * Initialize the driver.
62     *
63     * Validate configuration and perform all resource-intensive tasks needed to
64     * make the driver active.
65     *
66     * @throws ILSException
67     * @return void
68     */
69    public function init()
70    {
71        if (empty($this->config)) {
72            throw new ILSException('Configuration needs to be set.');
73        }
74
75        // Define Database Name
76        $tns = '(DESCRIPTION=' .
77                 '(ADDRESS_LIST=' .
78                   '(ADDRESS=' .
79                     '(PROTOCOL=TCP)' .
80                     '(HOST=' . $this->config['Catalog']['host'] . ')' .
81                     '(PORT=' . $this->config['Catalog']['port'] . ')' .
82                   ')' .
83                 ')' .
84                 '(CONNECT_DATA=' .
85                   '(SERVICE_NAME=' . $this->config['Catalog']['service'] . ')' .
86                 ')' .
87               ')';
88        $this->db = new \VuFind\Connection\Oracle(
89            $this->config['Catalog']['user'],
90            $this->config['Catalog']['password'],
91            $tns
92        );
93    }
94
95    /**
96     * Get Status
97     *
98     * This is responsible for retrieving the status information of a certain
99     * record.
100     *
101     * @param string $id The record id to retrieve the holdings for
102     *
103     * @throws ILSException
104     * @return mixed     On success, an associative array with the following keys:
105     * id, availability (boolean), status, location, reserve, callnumber.
106     */
107    public function getStatus($id)
108    {
109        $holding = [];
110
111        // Strip off the prefix from vtls exports
112        $db_id = str_replace('vtls', '', $id);
113
114        // Build SQL Statement
115        $sql = 'SELECT d.itemid AS item_id, c.due_date, s.name AS status, ' .
116                      's.status_code, l.name AS location, ' .
117                      'SUBSTR(d.location, 0, 1) as camp_id, ' .
118                      "DECODE(h.req_num, null, 'N', 'Y') AS reserve, " .
119                      'b.call_number AS bib_call_num, ' .
120                      'i.call_number AS item_call_num ' .
121               'FROM dbadmin.itemdetl2 d, dbadmin.location l, ' .
122                    'dbadmin.statdetl sd, dbadmin.item_status s, ' .
123                    'dbadmin.circdetl c, dbadmin.bibliographic_fields b, ' .
124                    'dbadmin.item_call_number i, ' .
125
126                    '(SELECT d1.itemid, MAX(h1.request_control_number) AS req_num ' .
127                    ' FROM   dbadmin.itemdetl2 d1, dbadmin.hlrcdetl h1 ' .
128                    ' WHERE (d1.itemid = h1.itemid ' .
129                    '    OR (d1.bibid  = h1.bibid ' .
130                    '        AND ' .
131                    '        h1.itemid is null)) ' .
132                    '   AND  d1.bibid  = :bib_id ' .
133                    ' GROUP BY d1.itemid ' .
134                    ') h ' .
135
136               'WHERE d.location = l.location_id ' .
137               'AND   d.itemid   = sd.itemid (+) ' .
138               'AND   sd.stat    = s.status_code (+) ' .
139               'AND   d.itemid   = c.itemid (+) ' .
140               'AND   d.itemid   = h.itemid (+) ' .
141               'AND   d.itemid   = i.itemid (+) ' .
142               'AND   d.bibid    = b.bib_id ' .
143               'AND   d.bibid    = :bib_id';
144
145        // Bind our bib_id and execute
146        $fields = ['bib_id:string' => $db_id];
147        $result = $this->db->simpleSelect($sql, $fields);
148
149        // If there are no results, lets try again because it has no items
150        if (count($result) == 0) {
151            $sql = 'SELECT b.call_number ' .
152                   'FROM dbadmin.bibliographic_fields b ' .
153                   'WHERE b.bib_id = :bib_id';
154            $result = $this->db->simpleSelect($sql, $fields);
155
156            if (count($result) > 0) {
157                $new_holding = [
158                    'id'           => $id,
159                    'availability' => false,
160                    'reserve'      => 'Y',
161                    'status'       => null,
162                    'location'     => 'Toowoomba',
163                    'campus'       => 'Toowoomba',
164                    'callnumber'   => $result[0]['CALL_NUMBER'],
165                    ];
166
167                switch ($result[0]['CALL_NUMBER']) {
168                    case 'ELECTRONIC RESOURCE':
169                        $new_holding['availability'] = true;
170                        $new_holding['status']       = null;
171                        $new_holding['location']     = 'Online';
172                        $new_holding['reserve']      = 'N';
173                        $holding[] = $new_holding;
174                        return $holding;
175                        break;
176                    case 'ON ORDER':
177                        $new_holding['status']       = 'ON ORDER';
178                        $new_holding['location']     = 'Pending...';
179                        $holding[] = $new_holding;
180                        return $holding;
181                        break;
182                    case 'ORDER CANCELLED':
183                        $new_holding['status']       = 'ORDER CANCELLED';
184                        $new_holding['location']     = 'None';
185                        $holding[] = $new_holding;
186                        return $holding;
187                        break;
188                    case 'MISSING':
189                        $new_holding['status']       = 'MISSING';
190                        $new_holding['location']     = 'Unknown';
191                        $holding[] = $new_holding;
192                        return $holding;
193                        break;
194
195                    default:
196                        // Still haven't found it. Let's check if it has a serials
197                        // holding location
198                        $call_number = $result[0]['CALL_NUMBER'];
199                        $sql = 'SELECT l.name, ' .
200                            'SUBSTR(l.location_id, 0, 1) as camp_id ' .
201                            'FROM dbadmin.holdlink h, location l ' .
202                            'WHERE h.location = l.location_id ' .
203                            'AND h.bibid = :bib_id';
204                        $result = $this->db->simpleSelect($sql, $fields);
205
206                        if (count($result) > 0) {
207                            foreach ($result as $r) {
208                                $tmp_holding = $new_holding;
209                                // TODO: create a configuration file mechanism for
210                                // specifying locations so we can eliminate these
211                                // hard-coded USQ-specific values.
212                                switch ($r['CAMP_ID']) {
213                                    case 4:
214                                        $campus = 'Fraser Coast';
215                                        break;
216                                    case 5:
217                                        $campus = 'Springfield';
218                                        break;
219                                    default:
220                                        $campus = 'Toowoomba';
221                                        break;
222                                }
223
224                                $tmp_holding['status']     = 'Not For Loan';
225                                $tmp_holding['location']   = $r['NAME'];
226                                $tmp_holding['reserve']    = 'N';
227                                $tmp_holding['campus']     = $campus;
228                                $tmp_holding['callnumber'] = $call_number;
229                                $holding[] = $tmp_holding;
230                            }
231                            return $holding;
232                        } else {
233                            // Still haven't found anything? Return nothing then...
234                            return $holding;
235                        }
236                        break;
237                }
238            } else {
239                // Still haven't found anything? Return nothing then...
240                return $holding;
241            }
242        }
243
244        // Build Holdings Array
245        foreach ($result as $row) {
246            // TODO: create a configuration file mechanism for specifying locations
247            // so we can eliminate these hard-coded USQ-specific values.
248            switch ($row['CAMP_ID']) {
249                case 4:
250                    $campus = 'Fraser Coast';
251                    break;
252                case 5:
253                    $campus = 'Springfield';
254                    break;
255                default:
256                    $campus = 'Toowoomba';
257                    break;
258            }
259
260            // If it has a due date... not available
261            if ($row['DUE_DATE'] != null) {
262                $available = false;
263            } else {
264                // All these statuses are also unavailable
265                // TODO: make these configurable through Virtua.ini.
266                switch ($row['STATUS_CODE']) {
267                    case '5402':  // '24 hour hold'
268                    case '4401':  // 'At Repair'
269                    case '5400':  // 'Being Processed'
270                    case '2101':  // 'Damaged Item'
271                    case '7400':  // 'Fraser Coast only'
272                    case '5700':  // 'IN TRANSIT'
273                    case '7700':  // 'Invoiced'
274                    case '3400':  // 'Invoiced - Re-ordered'
275                    case '4600':  // 'LONG OVERDUE'
276                    case '4700':  // 'MISSING'
277                    case '4705':  // 'ON HOLD'
278                    case '5710':  // 'REQUESTED FOR HOLD'
279                    case '5401':  // 'Staff Use'
280                        $available = false;
281                        break;
282                        // Otherwise it's available
283                    case '7200':  // 'External Loan Only'
284                    case '3100':  // 'In Library use only'
285                    case '2700':  // 'Limited Loan'
286                    case '2701':  // 'Not For Loan'
287                    case '2100':  // 'Not for loan'
288                    case '5401':  // 'On Display'
289                    default:
290                        $available = true;
291                        break;
292                }
293            }
294
295            $holding[] = [
296                'id'           => $id,
297                'availability' => $available,
298                'status'       => $row['STATUS'],
299                'location'     => $row['LOCATION'],
300                'reserve'      => $row['RESERVE'],
301                'campus'       => $campus,
302                'callnumber'   => $row['BIB_CALL_NUM'],
303                ];
304        }
305
306        return $holding;
307    }
308
309    /**
310     * Get Statuses
311     *
312     * This is responsible for retrieving the status information for a
313     * collection of records.
314     *
315     * @param array $idList The array of record ids to retrieve the status for
316     *
317     * @throws ILSException
318     * @return array        An array of getStatus() return values on success.
319     */
320    public function getStatuses($idList)
321    {
322        $status = [];
323        foreach ($idList as $id) {
324            $status[] = $this->getStatus($id);
325        }
326        return $status;
327    }
328
329    /**
330     * Get Holding
331     *
332     * This is responsible for retrieving the holding information of a certain
333     * record.
334     *
335     * @param string $id      The record id to retrieve the holdings for
336     * @param array  $patron  Patron data
337     * @param array  $options Extra options (not currently used)
338     *
339     * @throws DateException
340     * @throws ILSException
341     * @return array         On success, an associative array with the following
342     * keys: id, availability (boolean), status, location, reserve, callnumber,
343     * duedate, number, barcode.
344     *
345     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
346     */
347    public function getHolding($id, array $patron = null, array $options = [])
348    {
349        // Strip off the prefix from vtls exports
350        $db_id = str_replace('vtls', '', $id);
351        $fields = ['bib_id:string' => $db_id];
352
353        $holds = 'SELECT d1.itemid, MAX(h1.request_control_number) AS req_num ' .
354            'FROM   dbadmin.itemdetl2 d1, dbadmin.hlrcdetl h1 ' .
355            'WHERE  d1.itemid = h1.itemid ' .
356            'AND    d1.bibid  = :bib_id ' .
357            'GROUP BY d1.itemid';
358
359        $bib_reqs = 'SELECT h.bibid, count(*) as bib_req ' .
360            'FROM   hlrcdetl h ' .
361            'WHERE  h.itemid = 0 ' .
362            'GROUP BY h.bibid';
363
364        $item_reqs = 'SELECT h.itemid, count(*) as item_req ' .
365            'FROM   hlrcdetl h ' .
366            'WHERE  h.itemid <> 0 ' .
367            'GROUP BY h.itemid';
368
369        $issues = 'SELECT MAX(s.issue_id) AS latest_issue, h.bibid ' .
370            'FROM   serials_issue s, holdlink h ' .
371            'WHERE  h.bibid      = :bib_id ' .
372            'AND    h.holdingsid = s.holdingsid ' .
373            'GROUP BY h.bibid';
374
375        $reserve_class = 'SELECT DISTINCT item_id, item_class ' .
376            ' FROM reserve_item_v';
377
378        // Build SQL Statement
379        $sql = 'SELECT d.itemid as item_id, d.copyno, d.barcode, c.due_date, ' .
380            's.name as status, s.status_code, ' .
381            'l.name as location, l.location_id, b.call_number as bib_call_num, ' .
382            'i.call_number as item_call_num, ' .
383            'iss.latest_issue, r.item_class as reserve_item_class, ' .
384            'ic.item_class, d.units, ' .
385            'br.bib_req, ir.item_req ' .
386            'FROM   dbadmin.itemdetl2 d, dbadmin.location l, ' .
387            'dbadmin.statdetl sd, dbadmin.item_status s, ' .
388            'dbadmin.circdetl c, dbadmin.bibliographic_fields b, ' .
389            'dbadmin.item_call_number i, item_class_v ic, ' .
390            "($holds) h, ($bib_reqs) br, ($item_reqs) ir, ($issues) iss, " .
391            "($reserve_class) r " .
392            'WHERE  d.location  = l.location_id ' .
393            'AND    d.itemclass = ic.item_class_id ' .
394            'AND    d.itemid    = sd.itemid (+) ' .
395            'AND    sd.stat     = s.status_code (+) ' .
396            'AND    d.itemid    = c.itemid (+) ' .
397            'AND    d.itemid    = h.itemid (+) ' .
398            'AND    d.bibid     = br.bibid (+) ' .
399            'AND    d.itemid    = ir.itemid (+) ' .
400            'AND    d.itemid    = i.itemid (+) ' .
401            'AND    d.itemid    = r.item_id (+) ' .
402            'AND    d.bibid     = iss.bibid (+) ' .
403            'AND    d.bibid     = b.bib_id ' .
404            'AND    d.bibid     = :bib_id ' .
405            'ORDER BY l.location_id, d.units_sort_form desc, d.copyno';
406        //print "<div style='display:none;'>$sql</div>";
407
408        $result = $this->db->simpleSelect($sql, $fields);
409        if ($result === false) {
410            throw new ILSException($this->db->getHtmlError());
411        }
412
413        // Build Holdings Array
414        $holding = [];
415        foreach ($result as $row) {
416            // If it's reserved or has a due date... not available
417            if ($row['DUE_DATE'] != null || $row['REQ_COUNT'] != null) {
418                $available = false;
419            } else {
420                // All these statuses are also unavailable
421                // TODO: Make this configurable through Virtua.ini.
422                switch ($row['STATUS']) {
423                    case 'Fraser Coast only':
424                    case 'Being Processed':
425                    case 'Not For Loan':
426                    case 'Not for loan':
427                    case 'Invoiced':
428                    case 'IN TRANSIT':
429                    case 'Staff Use':
430                    case 'MISSING':
431                    case 'Damaged Item':
432                    case 'At Repair':
433                    case 'ON ORDER':
434                    case 'ON HOLD':
435                    case 'Springfield Only':
436                    case 'Part Order Received':
437                        $available = false;
438                        break;
439                        // Otherwise it's available
440                    default:
441                        $available = true;
442                        break;
443                }
444            }
445
446            // Call number
447            if ($row['ITEM_CALL_NUM'] != null) {
448                $call_num = $row['ITEM_CALL_NUM'];
449            } else {
450                $call_num = $row['BIB_CALL_NUM'];
451            }
452
453            $temp = [
454                'id'            => $id,
455                'availability'  => $available,
456                'status'        => $row['STATUS'],
457                'status_code'   => $row['STATUS_CODE'],
458                'location'      => $row['LOCATION'],
459                'location_code' => $row['LOCATION_ID'],
460                'reserve'       => $row['ITEM_REQ'] + $row['BIB_REQ'],
461                'callnumber'    => $call_num,
462                'duedate'       => $row['DUE_DATE'],
463                'number'        => $row['COPYNO'],
464                'barcode'       => $row['BARCODE'],
465                'itemclass'     => $row['ITEM_CLASS'],
466                'units'         => $row['UNITS'],
467                'resitemclass'  => $row['RESERVE_ITEM_CLASS'],
468                ];
469
470            // Add to the holdings array
471            $holding[] = $temp;
472        }
473
474        return (count($holding) != 0 && $patron['id'] != null)
475            ? $this->checkHoldAllowed($patron['id'], $holding) : $holding;
476    }
477
478    /**
479     * Check if this patron is allowed to place a request.
480     *   - Return the holdings array with true/false and a reason.
481     *
482     * Because of the location comparisons with the patron's
483     *   location that occur here we also take the oppurtunity
484     *   to push their "Home" location to the top.
485     *
486     * @param string $patron_id ID of patron
487     * @param array  $holdings  Holdings information from getHolding
488     *
489     * @return array            Holdings info augmented with req_allowed field
490     */
491    protected function checkHoldAllowed($patron_id, $holdings)
492    {
493        // Get the patron type
494        $sql = 'SELECT p.patron_type_id ' .
495            'FROM   patron_type_patron p, patron_barcode b ' .
496            'WHERE  b.patron_id = p.patron_id ' .
497            'AND    b.barcode   = :patron';
498        $fields = ['patron:string' => $patron_id];
499        $result = $this->db->simpleSelect($sql, $fields);
500
501        // We should have 1 row and only 1 row.
502        if (count($result) != 1) {
503            return $holdings;
504        }
505        $patron_type = $result[0]['PATRON_TYPE_ID'];
506
507        // A matrix of patron types and locations
508        // TODO: Make this configurable through Virtua.ini.
509        $type_list = [
510            'Externals'    => ['AX', 'AD', 'BX', 'BD', 'EX', 'ED', 'GX', 'GD',
511                'RX', 'SX', 'SD', 'XS', 'CC', 'RD'],
512            'Super User'   => ['LP', 'OC'],
513            // 1  => Toowoomba
514            '1' => ['AU', 'AM', 'BU', 'BM', 'EU', 'EM', 'GU', 'GM', 'RI', 'SU',
515                'SM', 'SC', 'RB', 'OT', 'ST', 'FC', 'LS'],
516            // 5  => Springfield
517            '5' => ['US', 'ES', 'PS', 'AS', 'GS', 'TS', 'TAS', 'EPS', 'XVS',
518                'XPS'],
519            // 4  => Fraser Coast
520            '4' => ['UF', 'PF', 'AF'],
521            ];
522        // Where is the patron from?
523        $location = '';
524        foreach ($type_list as $loc => $patron_types) {
525            if (in_array($patron_type, $patron_types)) {
526                $location = $loc;
527            }
528        }
529        // Requestable Statuses
530        // TODO: Make this configurable through Virtua.ini.
531        $status_list = [
532            '4401', // At Repair
533            '4705', // ON HOLD
534            '5400', // Being Processed
535            '5401', // On Display
536            '5402', // 24 Hour Hold
537            '5700',  // IN TRANSIT
538            ];
539        // Who can place reservations on available items
540        $available_locs = [
541            '1' => ['5', '4'],
542            '4' => [],
543            '5' => [],
544            ];
545        // Who can place reservations on UNavailable items
546        $unavailable_locs = [
547            '1' => ['1', '5', '4'],
548            '4' => [],
549            '5' => ['5'],
550            ];
551        // Who can place reservations on STATUS items
552        $status_locs = [
553            '1' => ['1', '5', '4'],
554            '4' => [],
555            '5' => ['5'],
556            ];
557
558        // Set a flag for super users, better then
559        //  the full function call inside the loop
560        if (in_array($patron_type, $type_list['Super User'])) {
561            $super_user = true;
562        } else {
563            $super_user = false;
564        }
565        // External Users cannot place a request
566        if (in_array($patron_type, $type_list['Externals'])) {
567            return $holdings;
568        }
569
570        /*
571         * Finished our basic tests, the real logic starts here
572         */
573        $sorted = []; // We'll put items from the patron's location in here
574        $return = []; // Everything else in here
575        foreach ($holdings as $h) {
576            $item_loc_code = null;
577            // Super Users (eg. Off-Camp, and Lending) can request anything
578            if ($super_user) {
579                $h['req_allowed'] = true;
580            } else {
581                // Everyone else we need to do some lookups
582
583                // Can't find their location?
584                if ($location == '') {
585                    $h['req_allowed'] = false;
586                } else {
587                    // Known location
588                    $can_req = false;
589                    // Details about this item -- note that we use 1/0 for
590                    // $item_is_out since digits display better in on screen
591                    // debugging than booleans.
592                    $item_is_out      = $h['duedate'] ? '1' : '0';
593                    $item_loc_code    = substr($h['location_code'], 0, 1);
594                    $item_stat_code   = $h['status_code'];
595
596                    // The item is on loan ...
597                    if ($item_is_out) {
598                        // ... and has a requestable status or no status ...
599                        if (
600                            in_array($item_stat_code, $status_list)
601                            || $item_stat_code === null
602                        ) {
603                            // ... can this user borrow on loan items at this
604                            // location?
605                            $can_req = in_array(
606                                $location,
607                                $unavailable_locs[$item_loc_code]
608                            );
609                        }
610                    } else {
611                        // The item is NOT on loan ...
612
613                        // ... and has a requestable status ...
614                        if (in_array($item_stat_code, $status_list)) {
615                            // ... can the user borrow status items at this location?
616                            $can_req
617                                = in_array($location, $status_locs[$item_loc_code]);
618                        } else {
619                            // ... and DOESN'T have a requestable status ...
620                            if ($item_stat_code !== null) {
621                                // ... but has a status, so it can't be requested.
622                            } else {
623                                // ... can the user borrow available items at this
624                                // location?
625                                $can_req = in_array(
626                                    $location,
627                                    $available_locs[$item_loc_code]
628                                );
629                            }
630                        }
631                    }
632                    /* DEBUGGING */
633                    //$can_req = $can_req ? "Y" : "N";
634                    //$h['req_allowed'] = "O:$item_is_out
635                    // L:$item_loc_code S:$item_stat_code : $can_req";
636                    /* Normal Return value */
637                    $h['req_allowed'] = $can_req;
638                }
639            }
640            // Item from this patron's "Home"
641            if ($item_loc_code == $location) {
642                $sorted[] = $h;
643            } else {
644                $return[] = $h;
645            }
646        } // End holdings loop
647        return array_merge($sorted, $return);
648    }
649
650    /* START - Serials functions */
651
652    /**
653     * Simple utility -- retrieve data matching a code
654     *
655     * @param array  $data Data to search
656     * @param string $code Code to search for
657     *
658     * @return mixed
659     */
660    protected function getField($data, $code)
661    {
662        foreach ($data as $d) {
663            if ($d['code'] == $code) {
664                return $d['data'];
665            }
666        }
667        return null;
668    }
669
670    /**
671     * Patterns coming in here are either all chrono
672     *    patterns, or no chrono patterns.
673     *
674     * This function takes care of the final string
675     *    render for each pattern subpart.
676     *
677     * @param array $data Data to render
678     *
679     * @return string
680     */
681    protected function renderPartSubPattern($data)
682    {
683        $end_time = $start_string = null;
684
685        // Handle empty patterns
686        if (count($data) == 0) {
687            return '';
688        }
689
690        // Test the first element
691        $is_chrono = strpos($data['pattern'][0], '(');
692        $return_string = '';
693
694        // NON chrono
695        if ($is_chrono === false) {
696            $i = 0;
697            foreach ($data['pattern'] as $d) {
698                $return_string .= $d . ' ' . $data['data'][$i] . ' ';
699                $i++;
700            }
701        } else {
702            // Chrono
703            // Important note: strtotime() expects
704            // 01/02/2000 = 2nd Jan 2000
705            // 01-02-2000 = 1st Feb 2000 <= Use hyphens
706            $pattern = implode('', $data['pattern']);
707            switch (strtolower(trim($pattern))) {
708                // Error case
709                case '':
710                    return null;
711                    break;
712                    // Year only
713                case '(year)':
714                    return $data['data'][0] . ' ';
715                    break;
716                    // Year + Month
717                case '(year)(month)':
718                    $months = explode('-', $data['data'][1]);
719                    $m = count($months);
720                    $years  = explode('-', $data['data'][0]);
721                    $y = count($years);
722                    $my = $m . $y;
723
724                    $start_time = strtotime('01-' . $months[0] . '-' . $years[0]);
725                    $end_string = 'F Y';
726
727                    switch ($my) {
728                        // January 2000 - February 2001
729                        case '22':
730                            $start_string = 'F Y';
731                            $end_time
732                                = strtotime('01-' . $months[1] . '-' . $years[1]);
733                            break;
734                            // January - February 2000
735                        case '21':
736                            $start_string = 'F';
737                            $end_time
738                                = strtotime('01-' . $months[1] . '-' . $years[0]);
739                            break;
740                            // January 2000
741                        case '11':
742                            $start_string = 'F Y';
743                            $end_time = null;
744                            break;
745                            // January 2000 - January 2001
746                        case '12':
747                            $start_string = 'F Y';
748                            $end_time
749                                = strtotime('01-' . $months[0] . '-' . $years[1]);
750                            break;
751                    }
752                    if ($end_time != null) {
753                        return date($start_string, $start_time) . ' - ' .
754                            date($end_string, $end_time);
755                    } else {
756                        return date($start_string, $start_time);
757                    }
758                    break;
759                    // Year + Month + Day
760                case '(year)(month)(day)':
761                    $days   = explode('-', $data['data'][2]);
762                    $d = count($days);
763                    $months = explode('-', $data['data'][1]);
764                    $m = count($months);
765                    $years  = explode('-', $data['data'][0]);
766                    $y = count($years);
767                    $dmy = $d . $m . $y;
768
769                    $start_time
770                        = strtotime($days[0] . '-' . $months[0] . '-' . $years[0]);
771                    $end_string = 'jS F Y';
772
773                    switch ($dmy) {
774                        // 01 January 2000
775                        case '111':
776                            $start_string = 'jS F Y';
777                            $end_time = null;
778                            break;
779                            // 01 January 2000 - 01 January 2001
780                        case '112':
781                            $start_string = 'jS F Y';
782                            $end_time = strtotime(
783                                $days[0] . '-' . $months[0] . '-' . $years[1]
784                            );
785                            break;
786                            // 01 January - 01 February 2000
787                        case '121':
788                            $start_string = 'jS F';
789                            $end_time = strtotime(
790                                $days[0] . '-' . $months[1] . '-' . $years[0]
791                            );
792                            break;
793                            // 01 January 2000 - 01 February 2001
794                        case '122':
795                            $start_string = 'jS F Y';
796                            $end_time = strtotime(
797                                $days[0] . '-' . $months[1] . '-' . $years[1]
798                            );
799                            break;
800                            // 01 - 02 January 2000
801                        case '211':
802                            $start_string = 'jS';
803                            $end_time = strtotime(
804                                $days[1] . '-' . $months[0] . '-' . $years[0]
805                            );
806                            break;
807                            // 01 January 2000 - 02 January 2001
808                        case '212':
809                            $start_string = 'jS F Y';
810                            $end_time = strtotime(
811                                $days[1] . '-' . $months[0] . '-' . $years[1]
812                            );
813                            break;
814                            // 01 January - 02 February 2000
815                        case '221':
816                            $start_string = 'jS F';
817                            $end_time = strtotime(
818                                $days[1] . '-' . $months[1] . '-' . $years[0]
819                            );
820                            break;
821                            // 01 January 2000 - 02 February 2001
822                        case '222':
823                            $start_string = 'jS F Y';
824                            $end_time = strtotime(
825                                $days[1] . '-' . $months[1] . '-' . $years[1]
826                            );
827                            break;
828                    }
829                    if ($end_time != null) {
830                        return date($start_string, $start_time) . ' - ' .
831                            date($end_string, $end_time);
832                    } else {
833                        return date($start_string, $start_time);
834                    }
835                    break;
836                default:
837                    $i = 0;
838                    foreach ($data['pattern'] as $d) {
839                        $return_string .= $d . ' ' . $data['data'][$i] . ' ';
840                        $i++;
841                    }
842                    break;
843            }
844        }
845
846        return $return_string;
847    }
848
849    /**
850     * Breaks up the full pattern into chrono and other
851     *   chrono = (year) etc... ie. gets replaced inline
852     *   other  = most enum holdings or 'Pt.'... ie. get concatenated
853     *
854     *   The same sub function handles both, but they must be
855     *    sent in like groups.
856     *
857     * @param array $data Data to render
858     *
859     * @return string
860     */
861    protected function renderSubPattern($data)
862    {
863        $return_string = '';
864        $sub_pattern = [];
865        $i = 0;
866        foreach ($data['pattern'] as $p) {
867            // Is this chrono pattern element?
868            $is_ch_pattern = strpos($p, '(');
869
870            // If it's not, render what we have so far
871            //   and clear the array
872            if ($is_ch_pattern === false) {
873                $return_string .= $this->renderPartSubPattern($sub_pattern);
874                $sub_pattern = [];
875            }
876
877            // Add the current element to the array
878            $sub_pattern['pattern'][] = $data['pattern'][$i];
879            $sub_pattern['data'][]    = $data['data'][$i];
880
881            // Now if the current element is not a
882            //   chrono pattern element we render it
883            //   on it's own and clear the array again
884            if ($is_ch_pattern === false) {
885                $return_string .= $this->renderPartSubPattern($sub_pattern);
886                $sub_pattern = [];
887            }
888            $i++;
889        }
890        // Render the last segment of the array
891        $return_string .= $this->renderPartSubPattern($sub_pattern);
892        return $return_string;
893    }
894
895    /**
896     * Currently used to handled note SUBfields
897     * eg. 863/z, not 866 generally
898     *   but anything non enum and chrono
899     *   related ends up here.
900     *
901     * @param array $data Data to render
902     *
903     * @return array
904     */
905    protected function renderOtherPattern($data)
906    {
907        $return = [];
908        $i = 0;
909        foreach ($data['data'] as $d) {
910            switch ($data['pattern_code'][$i]) {
911                case 'z':
912                    $return['notes'][] = $d;
913                    break;
914                default:
915                    $return[$data['pattern_code'][$i]][] = $d;
916                    break;
917            }
918            $i++;
919        }
920        return $return;
921    }
922
923    /**
924     * Renders individual holdings against a pattern
925     *   Note fields and prediction patterns are handled
926     *   separately
927     *
928     * @param array $patterns Pattern data
929     * @param array $field    Field data
930     *
931     * @return array
932     */
933    protected function renderPattern($patterns, $field)
934    {
935        $return = [];
936        // Check we have a pattern and the pattern exists
937        if (isset($field['pattern']) && isset($patterns[$field['pattern']])) {
938            // Enumeration, Chonology and Other fields
939            $enum_chrono = [
940                'a', 'b', 'c', 'd', 'e', 'f', 'i', 'j', 'k', 'l', 'm',
941            ];
942            $this_en_ch  = ['pattern' => [], 'data' => []];
943            $this_other  = ['pattern' => [], 'data' => []];
944
945            $pattern = $patterns[$field['pattern']];
946            // Foreach subfield
947            foreach ($field['data'] as $d) {
948                // Get the pattern for the subfield
949                $p = $this->getField($pattern, $d['code']);
950                // Put into the sub pattern
951                // ... Enumeration/Chronology
952                if (in_array($d['code'], $enum_chrono)) {
953                    $this_en_ch['pattern_code'][] = $d['code'];
954                    $this_en_ch['pattern'][] = $p;
955                    $this_en_ch['data'][] = $d['data'];
956                } else {
957                    // ... Other
958                    $this_other['pattern_code'][] = $d['code'];
959                    $this_other['pattern'][] = $p;
960                    $this_other['data'][] = $d['data'];
961                }
962
963                $return['en_ch'] = $this->renderSubPattern($this_en_ch);
964                $return['other'] = $this->renderOtherPattern($this_other);
965            }
966        } else {
967            // Otherwise just return the a subfield as a note
968            $return['other']['notes'][] = $this->getField($field['data'], 'a');
969        }
970        return $return;
971    }
972
973    /**
974     * A function turning holdings marc into an array of display ready strings.
975     *
976     * @param array $holdings_marc Holdings data from MARC.
977     *
978     * @return array
979     */
980    protected function renderSerialHoldings($holdings_marc)
981    {
982        // Convert to one line per tag
983        $data_set = [];
984        foreach ($holdings_marc as $row) {
985            if (
986                $row['SUBFIELD_DATA'] != null
987                && trim($row['SUBFIELD_DATA']) != ''
988            ) {
989                $data_set[$row['FIELD_SEQUENCE']][] = [
990                    'tag'  => trim($row['FIELD_TAG']),
991                    'code' => trim($row['SUBFIELD_CODE']),
992                    'data' => trim($row['SUBFIELD_DATA']),
993                ];
994            }
995        }
996
997        // Prepare the set for sorting on '8' subfields, also move the tag data out
998        $sort_set = [];
999        // Loop through each sequence
1000        foreach ($data_set as $row) {
1001            $tag  = '';
1002            $data = [];
1003            $sort_rule = '';
1004            $sort_order = '';
1005            // And each subfield
1006            foreach ($row as $subfield) {
1007                // Found the '8' subfield
1008                if ($subfield['code'] == 8) {
1009                    // Grab the tag for this sequence whilst here
1010                    $tag  = $subfield['tag'];
1011                    $sort = explode('.', $subfield['data']);
1012                    $sort_rule  = $sort[0];
1013                    $sort_order = $sort[1] ?? 0;
1014                    $sort_order = sprintf('%05d', $sort_order);
1015                } else {
1016                    // Everything else goes in the data bucket
1017                    $data[] = [
1018                        'code' => $subfield['code'],
1019                        'data' => $subfield['data'],
1020                    ];
1021                }
1022            }
1023            $sort_set[$sort_rule . '.' . $sort_order] = [
1024                'tag'  => $tag,
1025                'data' => $data,
1026            ];
1027        }
1028
1029        // Sort the float array
1030        krsort($sort_set);
1031
1032        // Remove the prediction patterns from the list
1033        //  and drop sort orders or holdings.
1034        $patterns = [];
1035        $holdings_data = [];
1036        foreach ($sort_set as $sort => $row) {
1037            $rule = explode('.', $sort);
1038            if ($row['tag'] == 853) {
1039                $patterns[$rule[0]] = $row['data'];
1040            } else {
1041                $holdings_data[] = [
1042                    'pattern' => $rule[0],
1043                    'data'    => $row['data'],
1044                ];
1045            }
1046        }
1047
1048        // Render all the holdings now
1049        $rendered_list = [];
1050        foreach ($holdings_data as $row) {
1051            $rendered_list[] = $this->renderPattern($patterns, $row);
1052        }
1053
1054        return $rendered_list;
1055    }
1056
1057    /**
1058     * Get Purchase History
1059     *
1060     * This is responsible for retrieving the acquisitions history data for the
1061     * specific record (usually recently received issues of a serial).
1062     *
1063     * @param string $id The record id to retrieve the info for
1064     *
1065     * @throws ILSException
1066     * @return array     An array with the acquisitions data on success.
1067     */
1068    public function getPurchaseHistory($id)
1069    {
1070        // Strip off the prefix from vtls exports
1071        $db_id = str_replace('vtls', '', $id);
1072        $fields = ['bib_id:string' => $db_id];
1073
1074        // Let's go check if this bib id is for a serial
1075        $sql = 'SELECT h.holdingsid, l.name ' .
1076            'FROM dbadmin.holdlink h, dbadmin.location l ' .
1077            'WHERE h.bibid   = :bib_id ' .
1078            'AND h.masked    = 0 ' .
1079            'AND h.location  = l.location_id';
1080
1081        $result = $this->db->simpleSelect($sql, $fields);
1082
1083        // Results indicate serial holdings
1084        if (count($result) == 0) {
1085            return [];
1086        }
1087
1088        $sql = 'SELECT * ' .
1089            'FROM dbadmin.iso_2709 i ' .
1090            'WHERE i.id = :hid ' .
1091            'AND i.idtype = 104 ' .
1092            "AND i.field_tag in ('853', '863', '866') " .
1093            'ORDER BY i.field_sequence, i.subfield_sequence';
1094
1095        $data = [];
1096        foreach ($result as $row) {
1097            $fields = ['hid:string' => $row['HOLDINGSID']];
1098            $hresult = $this->db->simpleSelect($sql, $fields);
1099            $data[$row['NAME']] = $this->renderSerialHoldings($hresult);
1100        }
1101
1102        return $data;
1103    }
1104
1105    /**
1106     *  Used for TESTING only. Grabs all prediction
1107     *     patterns in the system for analysis
1108     *
1109     * @return array
1110     */
1111    public function getAll853()
1112    {
1113        $sql = 'SELECT * ' .
1114            'FROM dbadmin.iso_2709 i ' .
1115            'WHERE i.idtype = 104 ' .
1116            "AND i.field_tag in ('853') " .
1117            'ORDER BY i.field_sequence, i.subfield_sequence';
1118        $hresult = $this->db->simpleSelect($sql);
1119        if (count($hresult) == 0) {
1120            return null;
1121        }
1122
1123        $data_set = [];
1124        foreach ($hresult as $row) {
1125            if (
1126                $row['SUBFIELD_DATA'] != null
1127                && trim($row['SUBFIELD_DATA']) != ''
1128            ) {
1129                $data_set[$row['ID'] . '_' . $row['FIELD_SEQUENCE']][] = [
1130                    'id'   => trim($row['ID']),
1131                    'code' => trim($row['SUBFIELD_CODE']),
1132                    'data' => trim($row['SUBFIELD_DATA']),
1133                    ];
1134            }
1135        }
1136        return $data_set;
1137    }
1138
1139    /* END - Serials functions */
1140
1141    /**
1142     * Patron Login
1143     *
1144     * This is responsible for authenticating a patron against the catalog.
1145     *
1146     * @param string $barcode  The patron barcode
1147     * @param string $password The patron password
1148     *
1149     * @throws ILSException
1150     * @return mixed           Associative array of patron info on successful login,
1151     * null on unsuccessful login.
1152     */
1153    public function patronLogin($barcode, $password)
1154    {
1155        $sql = 'SELECT i.id, b.barcode, i.subfield_data AS password, p.name, ' .
1156            'p.e_mail_address_primary, p.department ' .
1157            'FROM dbadmin.iso_2709 i, dbadmin.patron p, dbadmin.patron_barcode b ' .
1158            'WHERE i.idtype        = 105 ' .
1159            "AND   i.field_tag     = '015' " .
1160            "AND   i.subfield_code = 'b' " .
1161            'AND   p.patron_id     = i.id ' .
1162            'AND   b.patron_id     = i.id ' .
1163            'AND   i.id = ( ' .
1164            '  SELECT p.patron_id AS id ' .
1165            '  FROM   dbadmin.patron_barcode p ' .
1166            '  WHERE  UPPER(p.barcode)    = UPPER(:barcode) ' .
1167            ')';
1168
1169        $fields = ['barcode:string' => $barcode];
1170        $result = $this->db->simpleSelect($sql, $fields);
1171
1172        if (count($result) > 0) {
1173            // Valid Password
1174            if ($result[0]['PASSWORD'] == $password) {
1175                $user = [];
1176                $split      = strpos($result[0]['NAME'], ',');
1177                $last_name  = trim(substr($result[0]['NAME'], 0, $split));
1178                $first_name = trim(substr($result[0]['NAME'], $split + 1));
1179                $split      = strpos($first_name, ' ');
1180                if ($split !== false) {
1181                    $first_name = trim(substr($first_name, 0, $split));
1182                }
1183
1184                $user['id']           = trim($result[0]['ID']);
1185                $user['firstname']    = trim($first_name);
1186                $user['lastname']     = trim($last_name);
1187                $user['cat_username'] = strtoupper(trim($result[0]['BARCODE']));
1188                $user['cat_password'] = trim($result[0]['PASSWORD']);
1189                $user['email']        = trim($result[0]['E_MAIL_ADDRESS_PRIMARY']);
1190                $user['major']        = trim($result[0]['DEPARTMENT']);
1191                $user['college']      = null;
1192
1193                return $user;
1194            } else {
1195                // Invalid Password
1196                return null;
1197            }
1198        } else {
1199            // User not found
1200            return null;
1201        }
1202    }
1203
1204    /**
1205     * Get Patron Profile
1206     *
1207     * This is responsible for retrieving the profile for a specific patron.
1208     *
1209     * @param array $patron The patron array
1210     *
1211     * @throws ILSException
1212     * @return array        Array of the patron's profile data on success.
1213     */
1214    public function getMyProfile($patron)
1215    {
1216        $sql = 'SELECT p.name, p.street_address_1, p.street_address_2, p.city, ' .
1217            'p.postal_code, p.telephone_primary, t.name as patron_type ' .
1218            'FROM  dbadmin.patron_type_patron pt, dbadmin.patron p, ' .
1219            'dbadmin.patron_type t ' .
1220            'WHERE p.patron_id      = pt.patron_id ' .
1221            'AND   t.patron_type_id = pt.patron_type_id ' .
1222            'AND   p.patron_id      = :patron_id';
1223
1224        $fields = ['patron_id:string' => $patron['id']];
1225        $result = $this->db->simpleSelect($sql, $fields);
1226
1227        if (count($result) > 0) {
1228            $split      = strpos($result[0]['NAME'], ',');
1229            $last_name  = substr($result[0]['NAME'], 0, $split);
1230            $first_name = substr($result[0]['NAME'], $split + 1);
1231            $split      = strpos($result[0]['NAME'], ' ');
1232            if ($split !== false) {
1233                $first_name = substr($first_name, 0, $split);
1234            }
1235
1236            $patron = [
1237                'firstname' => trim($first_name),
1238                'lastname'  => trim($last_name),
1239                'address1'  => trim($result[0]['STREET_ADDRESS_1']),
1240                'address2'  => trim($result[0]['STREET_ADDRESS_2']),
1241                'zip'       => trim($result[0]['POSTAL_CODE']),
1242                'phone'     => trim($result[0]['TELEPHONE_PRIMARY']),
1243                'group'     => trim($result[0]['PATRON_TYPE']),
1244                ];
1245
1246            if ($result[0]['CITY'] != null) {
1247                if (strlen($patron['address2']) > 0) {
1248                    $patron['address2'] .= ', ' . trim($result[0]['CITY']);
1249                } else {
1250                    $patron['address2'] = trim($result[0]['CITY']);
1251                }
1252            }
1253
1254            return $patron;
1255        } else {
1256            return null;
1257        }
1258    }
1259
1260    /**
1261     * Get Patron Fines
1262     *
1263     * This is responsible for retrieving all fines by a specific patron.
1264     *
1265     * @param array $patron The patron array from patronLogin
1266     *
1267     * @throws DateException
1268     * @throws ILSException
1269     * @return mixed        Array of the patron's fines on success.
1270     */
1271    public function getMyFines($patron)
1272    {
1273        $fineList = [];
1274
1275        $sql = 'SELECT a.assessment_amount fine_amount, f.description, ' .
1276            'a.balance, a.item_due_date due_date, i.bibid bib_id ' .
1277            'FROM  patron_account a, fine_code_v f, itemdetl2 i ' .
1278            'WHERE a.state        = 0 ' .
1279            'AND   a.balance      > 0 ' .
1280            'AND   a.itemid       = i.itemid ' .
1281            'AND   a.fine_code_id = f.fine_code_id ' .
1282            'AND   a.patron_id    = :patron_id';
1283
1284        $fields = ['patron_id:string' => $patron['id']];
1285        $result = $this->db->simpleSelect($sql, $fields);
1286
1287        if (count($result) > 0) {
1288            foreach ($result as $row) {
1289                $fineList[] = [
1290                    'amount'   => $row['FINE_AMOUNT'] * 100,
1291                    'fine'     => $row['DESCRIPTION'],
1292                    'balance'  => $row['BALANCE'] * 100,
1293                    'duedate'  => $row['DUE_DATE'],
1294                    'id'       => 'vtls' . sprintf('%09d', (int)$row['BIB_ID']),
1295                    ];
1296            }
1297        }
1298        return $fineList;
1299    }
1300
1301    /**
1302     * Get Patron Holds
1303     *
1304     * This is responsible for retrieving all holds by a specific patron.
1305     *
1306     * @param array $patron The patron array from patronLogin
1307     *
1308     * @throws DateException
1309     * @throws ILSException
1310     * @return array        Array of the patron's holds on success.
1311     */
1312    public function getMyHolds($patron)
1313    {
1314        $holdList = [];
1315
1316        $sql = 'SELECT h.bibid, l.name pickup_location, h.pickup_any_location, ' .
1317            'h.date_last_needed, h.date_placed, h.request_control_number ' .
1318            'FROM  dbadmin.hlrcdetl h, dbadmin.location l ' .
1319            'WHERE h.pickup_location = l.location_id ' .
1320            'AND   h.patron_id       = :patron_id';
1321
1322        $fields = ['patron_id:string' => $patron['id']];
1323        $result = $this->db->simpleSelect($sql, $fields);
1324
1325        if (count($result) > 0) {
1326            foreach ($result as $row) {
1327                $holdList[] = [
1328                    'id'       => 'vtls' . sprintf('%09d', (int)$row['BIBID']),
1329                    'location' => $row['PICKUP_LOCATION'],
1330                    'expire'   => $row['DATE_LAST_NEEDED'],
1331                    'create'   => $row['DATE_PLACED'],
1332                    'reqnum'   => $row['REQUEST_CONTROL_NUMBER'],
1333                    ];
1334            }
1335        }
1336        return $holdList;
1337    }
1338
1339    /**
1340     * Get Patron Transactions
1341     *
1342     * This is responsible for retrieving all transactions (i.e. checked out items)
1343     * by a specific patron.
1344     *
1345     * @param array $patron The patron array from patronLogin
1346     *
1347     * @throws DateException
1348     * @throws ILSException
1349     * @return array        Array of the patron's transactions on success.
1350     */
1351    public function getMyTransactions($patron)
1352    {
1353        $transList = [];
1354
1355        $bib_reqs = 'SELECT h.bibid, count(*) as bib_req ' .
1356            'FROM   hlrcdetl h ' .
1357            'WHERE  h.itemid = 0 ' .
1358            'GROUP BY h.bibid';
1359        $item_reqs = 'SELECT h.itemid, count(*) as item_req ' .
1360            'FROM   hlrcdetl h ' .
1361            'WHERE  h.itemid <> 0 ' .
1362            'GROUP BY h.itemid';
1363
1364        $sql = 'SELECT i.bibid, i.itemid, c.due_date, i.barcode, ' .
1365            'c.renew_count, (br.bib_req + ir.item_req) as req_count ' .
1366            "FROM   circdetl c, itemdetl2 i, ($bib_reqs) br, ($item_reqs) ir " .
1367            'WHERE  c.itemid    = i.itemid ' .
1368            'AND    i.bibid     = br.bibid (+) ' .
1369            'AND    i.itemid    = ir.itemid (+) ' .
1370            'AND    c.patron_id = :patron_id ' .
1371            'ORDER BY c.due_date';
1372
1373        $fields = ['patron_id:string' => $patron['id']];
1374        $result = $this->db->simpleSelect($sql, $fields);
1375
1376        if (count($result) > 0) {
1377            foreach ($result as $row) {
1378                $transList[] = [
1379                    'duedate' => $row['DUE_DATE'],
1380                    'barcode' => $row['BARCODE'],
1381                    'renew'   => $row['RENEW_COUNT'],
1382                    'request' => $row['REQ_COUNT'],
1383                    // IDs need to show as 'vtls000589589'
1384                    'id'      => 'vtls' . sprintf('%09d', (int)$row['BIBID']),
1385                    ];
1386            }
1387        }
1388        return $transList;
1389    }
1390
1391    /**
1392     * Get Courses
1393     *
1394     * Obtain a list of courses for use in limiting the reserves list.
1395     *
1396     * @throws ILSException
1397     * @return array An associative array with key = ID, value = name.
1398     */
1399    public function getCourses()
1400    {
1401        $courseList = [];
1402
1403        $sql = 'SELECT DISTINCT l.course_id ' .
1404            'FROM reserve_list_v l, reserve_item_v i ' .
1405            'WHERE l.Reserve_list_id = i.Reserve_list_id ' .
1406            'AND SYSDATE BETWEEN i.Begin_date AND i.End_date ' .
1407            'ORDER BY l.course_id';
1408        $result = $this->db->simpleSelect($sql);
1409
1410        if (count($result) > 0) {
1411            foreach ($result as $row) {
1412                $courseList[] = $row['COURSE_ID'];
1413            }
1414        }
1415
1416        return $courseList;
1417    }
1418
1419    /**
1420     * Find Reserves
1421     *
1422     * Obtain information on course reserves.
1423     *
1424     * @param string $course ID from getCourses (empty string to match all)
1425     * @param string $inst   ID from getInstructors (empty string to match all)
1426     * @param string $dept   ID from getDepartments (empty string to match all)
1427     *
1428     * @throws ILSException
1429     * @return array An array of associative arrays representing reserve items.
1430     *
1431     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1432     */
1433    public function findReserves($course, $inst = false, $dept = false)
1434    {
1435        $recordList = [];
1436
1437        $sql = 'SELECT DISTINCT d.bibid ' .
1438            'FROM reserve_item_v i, reserve_list_v l, itemdetl2 d ' .
1439            'WHERE i.Reserve_list_id = l.Reserve_list_id ' .
1440            'AND SYSDATE BETWEEN i.Begin_date AND i.End_date ' .
1441            'AND i.Item_id = d.itemid ' .
1442            'AND l.Course_id = :course';
1443        $fields = ['course:string' => $course];
1444        $result = $this->db->simpleSelect($sql, $fields);
1445
1446        if (count($result) > 0) {
1447            foreach ($result as $row) {
1448                $recordList[] = 'vtls' . sprintf('%09d', (int)$row['BIBID']);
1449            }
1450        }
1451
1452        return $recordList;
1453    }
1454
1455    /**
1456     * Retrieve the opening hours for all campuses.
1457     *   - Used on the home page to show time information.
1458     *
1459     * @param string $fake_time Optional time string (for debugging purposes)
1460     *
1461     * @return array            Opening hours information.
1462     */
1463    public function getOpeningHours($fake_time = null)
1464    {
1465        // Change this value for debugging
1466        // eg. strtotime('25-12-2009') = Christmas
1467        if ($fake_time) {
1468            $time = strtotime($fake_time);
1469        } else {
1470            $time = strtotime('now');
1471        }
1472        $today = date('d-m-Y', $time);
1473        $time_format = 'H:i:s';
1474
1475        // Fix Date Handling
1476        $this->db->simpleSql(
1477            "ALTER SESSION SET NLS_DATE_FORMAT = 'DD-MM-YY HH24:MI:SS'"
1478        );
1479
1480        // Normal opening hours
1481        $sql = 'SELECT campus, open_time, close_time, status ' .
1482            'FROM usq_sr_open_normal n ' .
1483            'WHERE UPPER(dayofweek) = UPPER(:dow)';
1484        $fields = ['dow:string' => date('l', $time)];
1485        $result = $this->db->simpleSelect($sql, $fields);
1486        if (count($result) == 0) {
1487            return [];
1488        }
1489
1490        // Create our return data structure
1491        $times = [];
1492        foreach ($result as $row) {
1493            // Remember times come out with no date, add in today.
1494            $times[$row['CAMPUS']] = [
1495                'open'   => "$today " .
1496                    date($time_format, strtotime($row['OPEN_TIME'])),
1497                'close'  => "$today " .
1498                    date($time_format, strtotime($row['CLOSE_TIME'])),
1499                'status' => $row['STATUS'],
1500                ];
1501        }
1502
1503        // Opening hours exceptions
1504        $day  = strtolower(date('D', $time));
1505        // Lowest priority row (numericaly, ie. 1 = most important)
1506        $priority = 'SELECT e.campus, MIN(e.priority) as priority ' .
1507            'FROM   usq_sr_open_except e ' .
1508            "WHERE to_date(:today,'dd/mm/yyyy') " .
1509            'BETWEEN e.except_date_from AND e.except_date_to ' .
1510            "  AND app_$day = 1 " .
1511            'GROUP BY e.campus';
1512        // Retrieve Exceptions
1513        $sql = 'SELECT e.campus, e.open_time, e.close_time, e.status, e.reason ' .
1514            "FROM ($priority) p, usq_sr_open_except e " .
1515            'WHERE e.campus   = p.campus ' .
1516            'AND   e.priority = p.priority ' .
1517            "AND   to_date(:today,'dd/mm/yyyy') " .
1518            'BETWEEN e.except_date_from AND e.except_date_to ' .
1519            "AND   app_$day = 1";
1520        $fields = ['today:string' => date('d/m/Y', $time)];
1521        $exceptions = $this->db->simpleSelect($sql, $fields);
1522
1523        foreach ($exceptions as $row) {
1524            $times[$row['CAMPUS']] = [
1525                // Remember times come out with no date, add in today.
1526                'open'   => "$today "
1527                    . date($time_format, strtotime($row['OPEN_TIME'])),
1528                'close'  => "$today "
1529                    . date($time_format, strtotime($row['CLOSE_TIME'])),
1530                'status' => $row['STATUS'],
1531                'reason' => $row['REASON'],
1532            ];
1533        }
1534        return $times;
1535    }
1536
1537    /**
1538     * Place Hold
1539     *
1540     * Attempts to place a hold or recall on a particular item and returns
1541     * an array with result details or throws an exception on failure of support
1542     * classes
1543     *
1544     * @param array $holdDetails An array of item and patron data
1545     *
1546     * @throws ILSException
1547     * @return mixed An array of data on the request including
1548     * whether or not it was successful and a system message (if available)
1549     */
1550    public function placeHold($holdDetails)
1551    {
1552        // Extract key values from the hold details
1553        $patron_id = $holdDetails['patron'];
1554        $req_level = $holdDetails['req_level'];
1555        $pickup_loc = $holdDetails['pickUpLocation'];
1556        $item_id = $holdDetails['item_id'];
1557        $last_date = $holdDetails['requiredBy'];
1558
1559        // Assume an error response:
1560        $response = ['success' => false, 'status' => 'hold_error_fail'];
1561
1562        // Validate input
1563        //  * Request level
1564        $allowed_req_levels = [
1565            'item'   => 0,
1566            'bib'    => 1,
1567            'volume' => 2,
1568            ];
1569        if (!in_array($req_level, array_keys($allowed_req_levels))) {
1570            return $response;
1571        }
1572        //  * Pickup Location
1573        $allowed_pickup_locs = [
1574            'Toowoomba'    => '10000',
1575            'Fraser Coast' => '40000',
1576            'Springfield'  => '50000',
1577            ];
1578        if (!in_array($pickup_loc, array_keys($allowed_pickup_locs))) {
1579            return $response;
1580        }
1581        //  * Last Date - Valid date and a future date
1582        $ts_last_date = strtotime($last_date);
1583        if ($ts_last_date == 0 || $ts_last_date <= strtotime('now')) {
1584            return $response;
1585        }
1586
1587        // Still here? Guess the request is valid, lets send it to virtua
1588        $virtua_url = $this->getApiBaseUrl() . '?' .
1589            // Standard stuff
1590            'search=NOSRCH&function=REQUESTS&reqreqtype=0&reqtype=0' .
1591            '&reqscr=2&reqreqlevel=2&reqidtype=127&reqmincircperiod=' .
1592            // Item ID
1593            "&reqidno=$item_id" .
1594            // Patron barcode
1595            "&reqpatronbarcode=$patron_id" .
1596            // Request Level
1597            '&reqautoadjustlevel=' . $allowed_req_levels[$req_level] .
1598            // Pickup location
1599            '&reqpickuplocation=' . $allowed_pickup_locs[$pickup_loc] .
1600            // Last Date
1601            '&reqexpireday=' . date('j', $ts_last_date) .
1602            '&reqexpiremonth=' . date('n', $ts_last_date) .
1603            '&reqexpireyear=' . date('Y', $ts_last_date);
1604
1605        // Get the response
1606        $result = $this->httpRequest($virtua_url);
1607
1608        // Look for an error message
1609        $error_message = 'Your request was not processed.';
1610        $test = strpos($result, $error_message);
1611
1612        // If we succeeded, override the default fail message with success:
1613        if ($test === false) {
1614            $response['success'] = true;
1615            $response['status'] = 'hold_success';
1616        }
1617
1618        return $response;
1619    }
1620
1621    /**
1622     * Get Cancel Hold Details
1623     *
1624     * In order to cancel a hold, Voyager requires the patron details an item ID
1625     * and a recall ID. This function returns the item id and recall id as a string
1626     * separated by a pipe, which is then submitted as form data in Hold.php. This
1627     * value is then extracted by the CancelHolds function.
1628     *
1629     * @param array $holdDetails A single hold array from getMyHolds
1630     * @param array $patron      Patron information from patronLogin
1631     *
1632     * @return string Data for use in a form field
1633     *
1634     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1635     */
1636    public function getCancelHoldDetails($holdDetails, $patron = [])
1637    {
1638        throw new \Exception('TODO: implement getCancelHoldDetails.');
1639    }
1640
1641    /**
1642     * Cancel Holds
1643     *
1644     * Attempts to Cancel a hold or recall on a particular item. The
1645     * data in $cancelDetails['details'] is determined by getCancelHoldDetails().
1646     *
1647     * @param array $cancelDetails An array of item and patron data
1648     *
1649     * @return array               An array of data on each request including
1650     * whether or not it was successful and a system message (if available)
1651     *
1652     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1653     */
1654    public function cancelHolds($cancelDetails)
1655    {
1656        // TODO: implement standard VuFind holds API; utilize cancelHold()
1657        // below as a support method.
1658        throw new \Exception('cancelHolds() is not supported yet.');
1659    }
1660
1661    /**
1662     * Cancel a request in virtua.
1663     *   - Return true/false for success/failure.
1664     *
1665     * @param string $request_number ID of hold to cancel
1666     *
1667     * @return bool
1668     */
1669    protected function cancelHold($request_number)
1670    {
1671        $virtua_url = $this->getApiBaseUrl() . '?' .
1672            // Standard stuff
1673            'search=NOSRCH&function=REQUESTS&reqreqtype=1&reqtype=0' .
1674            '&reqscr=4&reqreqlevel=2&reqidtype=127' .
1675            //"&reqidno=1000651541" .
1676            "&reqctrlnum=$request_number";
1677
1678        try {
1679            // Get the response
1680            $result = $this->httpRequest($virtua_url);
1681            // Invalid server response. It's probably down
1682        } catch (\Exception $e) {
1683            return false;
1684        }
1685
1686        // Look for an error message
1687        $error_message = 'Your request could not be deleted.';
1688        $test = strpos($result, $error_message);
1689
1690        // Return true unless we find the error
1691        if ($test === false) {
1692            return true;
1693        } else {
1694            return false;
1695        }
1696    }
1697
1698    /**
1699     * Fake a virtua login on the patron's behalf.
1700     *   - Return a session id.
1701     *
1702     * @param array $patron Array with cat_username/cat_password keys
1703     *
1704     * @return string       Session ID
1705     */
1706    protected function fakeLogin($patron)
1707    {
1708        $virtua_url = $this->getApiBaseUrl();
1709        $postParams = [
1710            'SourceScreen' => 'INITREQ',
1711            'conf' => '.&#047;chameleon.conf',
1712            'elementcount' => '1',
1713            'function' => 'PATRONATTEMPT',
1714            'host' => $this->config['Catalog']['host_string'],
1715            'lng' => $this->getConfiguredLanguage(),
1716            'login' => '1',
1717            'pos' => '1',
1718            'rootsearch' => 'KEYWORD',
1719            'search' => 'NOSRCH',
1720            'skin' => 'homepage',
1721            'patronid' => $patron['cat_username'],
1722            'patronpassword' => $patron['cat_password'],
1723            'patronhost' => $this->config['Catalog']['patron_host'],
1724        ];
1725
1726        // Get the response
1727        $result = $this->httpRequest($virtua_url, $postParams);
1728        // Now find the sessionid. There should be one in the meta tags,
1729        // so we can look for the first one in the document
1730        // eg. <meta http-equiv="Refresh" content="30000;
1731        // url=http://libwebtest2.usq.edu.au:80/cgi-bin/chameleon?sessionid=
1732        //2009071712483605131&amp;skin=homepage&amp;lng=en&amp;inst=
1733        //consortium&amp;conf=.%26%23047%3bchameleon.conf&amp;timedout=1" />
1734        $start = strpos($result, 'sessionid=') + 10;
1735        $end   = strpos($result, '&amp;skin=');
1736        return substr($result, $start, $end - $start);
1737    }
1738
1739    /**
1740     * Get Renew Details
1741     *
1742     * In order to renew an item, Voyager requires the patron details and an item
1743     * id. This function returns the item id as a string which is then used
1744     * as submitted form data in checkedOut.php. This value is then extracted by
1745     * the RenewMyItems function.
1746     *
1747     * @param array $checkOutDetails An array of item data
1748     *
1749     * @return string Data for use in a form field
1750     *
1751     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
1752     */
1753    public function getRenewDetails($checkOutDetails)
1754    {
1755        throw new \Exception('TODO: implement getRenewDetails');
1756    }
1757
1758    /**
1759     * Renew My Items
1760     *
1761     * Function for attempting to renew a patron's items. The data in
1762     * $renewDetails['details'] is determined by getRenewDetails().
1763     *
1764     * @param array $renewDetails An array of data required for renewing items
1765     * including the Patron ID and an array of renewal IDS
1766     *
1767     * @return array              An array of renewal information keyed by item ID
1768     */
1769    public function renewMyItems($renewDetails)
1770    {
1771        $item_list = $renewDetails['details'];
1772        $patron = $renewDetails['patron'];
1773
1774        // Get items out on loan at the moment
1775        $result = $this->getMyTransactions($patron);
1776        // Make it more accessible - by barcode
1777        $initial = [];
1778        foreach ($result as $row) {
1779            $initial[$row['barcode']] = $row;
1780        }
1781
1782        // Fake a login to get an authenticated session
1783        $session_id = $this->fakeLogin($patron);
1784
1785        $virtua_url = $this->getApiBaseUrl();
1786
1787        // Have to use raw post data because of the way
1788        //   virtua expects the barcodes to come across.
1789        $post_data  = 'function=' . 'RENEWAL';
1790        $post_data .= '&search=' . 'PATRON';
1791        $post_data .= '&sessionid=' . "$session_id";
1792        $post_data .= '&skin=' . 'homepage';
1793        $post_data .= '&lng=' . $this->getConfiguredLanguage();
1794        $post_data .= '&inst=' . 'consortium';
1795        $post_data .= '&conf=' . urlencode('.&#047;chameleon.conf');
1796        $post_data .= '&u1=' . '12';
1797        $post_data .= '&SourceScreen=' . 'PATRONACTIVITY';
1798        $post_data .= '&pos=' . '1';
1799        $post_data .= '&patronid=' . $patron['cat_username'];
1800        $post_data .= '&patronhost='
1801            . urlencode($this->config['Catalog']['patron_host']);
1802        $post_data .= '&host='
1803            . urlencode($this->config['Catalog']['host_string']);
1804        $post_data .= '&itembarcode=' . implode('&itembarcode=', $item_list);
1805        $post_data .= '&submit=' . 'Renew';
1806        $post_data .= '&reset=' . 'Clear';
1807
1808        $result = $this->httpRequest($virtua_url, null, $post_data);
1809
1810        // Get items out on loan with renewed info
1811        $result = $this->getMyTransactions($patron);
1812
1813        // Foreach item currently on loan
1814        $return = [];
1815        foreach ($result as $row) {
1816            // Did we even attempt to renew?
1817            if (in_array($row['barcode'], $item_list)) {
1818                // Yes, so check if the due date changed
1819                if ($row['duedate'] != $initial[$row['barcode']]['duedate']) {
1820                    $row['error'] = false;
1821                    $row['renew_text'] = 'Item successfully renewed.';
1822                } else {
1823                    $row['error'] = true;
1824                    $row['renew_text'] = 'Item renewal failed.';
1825                }
1826                $return[] = $row;
1827            } else {
1828                // No attempt to renew this item
1829                $return[] = $row;
1830            }
1831        }
1832        return $return;
1833    }
1834
1835    /**
1836     * Get suppressed authority records
1837     *
1838     * @return array ID numbers of suppressed authority records in the system.
1839     */
1840    public function getSuppressedAuthorityRecords()
1841    {
1842        $list = [];
1843
1844        $sql = 'select auth_id ' .
1845            'from state_record_authority ' .
1846            'WHERE STATE_ID = 1';
1847
1848        $result = $this->db->simpleSelect($sql);
1849
1850        if ($result === false) {
1851            throw new ILSException(
1852                'An error occurred while connecting to Virtua'
1853            );
1854        }
1855
1856        foreach ($result as $row) {
1857            $list[] = 'vtls' . str_pad($row['AUTH_ID'], 9, '0', STR_PAD_LEFT);
1858        }
1859        return $list;
1860    }
1861
1862    /**
1863     * Support method -- get base URL for API requests.
1864     *
1865     * @return string
1866     */
1867    protected function getApiBaseUrl()
1868    {
1869        // Get the iPortal server
1870        $host = $this->config['Catalog']['webhost'];
1871        $path = isset($this->config['Catalog']['cgi_token'])
1872            ? trim($this->config['Catalog']['cgi_token'], '/')
1873            : 'cgi-bin';
1874        return "http://{$host}/{$path}/chameleon";
1875    }
1876
1877    /**
1878     * Support method -- determine the language from the configuration.
1879     *
1880     * @return string
1881     */
1882    protected function getConfiguredLanguage()
1883    {
1884        return $this->config['Catalog']['language'] ?? 'en';
1885    }
1886
1887    /**
1888     * Support method -- perform an HTTP request. This will be a GET request unless
1889     * either $postParams or $rawPost is set to a non-null value.
1890     *
1891     * @param string $url        Target URL for request
1892     * @param array  $postParams Associative array of POST parameters (null for
1893     * none).
1894     * @param string $rawPost    String representing raw POST parameters (null for
1895     * none).
1896     *
1897     * @throws ILSException
1898     * @return string Response body
1899     */
1900    protected function httpRequest($url, $postParams = null, $rawPost = null)
1901    {
1902        $method = (null === $postParams && null === $rawPost) ? 'GET' : 'POST';
1903
1904        try {
1905            $client = $this->httpService->createClient($url);
1906            if (is_array($postParams)) {
1907                $client->setParameterPost($postParams);
1908            }
1909            if (null !== $rawPost) {
1910                $client->setRawBody($rawPost);
1911                $client->setEncType('application/x-www-form-urlencoded');
1912            }
1913            $result = $client->setMethod($method)->send();
1914        } catch (\Exception $e) {
1915            $this->throwAsIlsException($e);
1916        }
1917
1918        if (!$result->isSuccess()) {
1919            throw new ILSException('HTTP error');
1920        }
1921
1922        return $result->getBody();
1923    }
1924
1925    /* Methods yet to be implemented -- see Voyager driver for examples
1926
1927    public function getNewItems($page, $limit, $daysOld, $fundId = null)
1928
1929    public function getFunds()
1930
1931    public function getSuppressedRecords()
1932     */
1933}