Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
20.95% |
207 / 988 |
|
21.43% |
9 / 42 |
CRAP | |
0.00% |
0 / 1 |
Alma | |
20.95% |
207 / 988 |
|
21.43% |
9 / 42 |
27280.68 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
init | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
makeRequest | |
0.00% |
0 / 65 |
|
0.00% |
0 / 1 |
272 | |||
getItemAvailabilityAndStatus | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
10 | |||
getItemStatusFromLocationTypeMap | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
6 | |||
getHolding | |
100.00% |
59 / 59 |
|
100.00% |
1 / 1 |
14 | |||
checkRequestIsValid | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
56 | |||
getRequestBlocks | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getAccountBlocks | |
0.00% |
0 / 28 |
|
0.00% |
0 / 1 |
132 | |||
getFulfillmentUnitByLocation | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
createAlmaUser | |
0.00% |
0 / 71 |
|
0.00% |
0 / 1 |
72 | |||
patronLogin | |
0.00% |
0 / 91 |
|
0.00% |
0 / 1 |
156 | |||
getMyProfile | |
0.00% |
0 / 46 |
|
0.00% |
0 / 1 |
306 | |||
getMyFines | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
6 | |||
getMyHolds | |
0.00% |
0 / 51 |
|
0.00% |
0 / 1 |
132 | |||
cancelHolds | |
0.00% |
0 / 35 |
|
0.00% |
0 / 1 |
20 | |||
updateHolds | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
20 | |||
getMyStorageRetrievalRequests | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
30 | |||
getMyILLRequests | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
30 | |||
getMyTransactions | |
0.00% |
0 / 59 |
|
0.00% |
0 / 1 |
156 | |||
getRenewDetails | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
renewMyItems | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
12 | |||
getStatus | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getStatuses | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getPurchaseHistory | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getConfig | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
42 | |||
placeHold | |
0.00% |
0 / 61 |
|
0.00% |
0 / 1 |
42 | |||
getPickupLocations | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
getCourses | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
findReserves | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
12 | |||
parseDate | |
48.84% |
21 / 43 |
|
0.00% |
0 / 1 |
40.25 | |||
supportsMethod | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getInventoryTypes | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
3 | |||
getStatusesForInventoryTypes | |
51.95% |
40 / 77 |
|
0.00% |
0 / 1 |
53.95 | |||
getPreferredEmail | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
42 | |||
getTranslatableString | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
2.02 | |||
getTranslatableStatusString | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
2.03 | |||
getItemLocation | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getItemLocationType | |
50.00% |
4 / 8 |
|
0.00% |
0 / 1 |
6.00 | |||
getLocationType | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getLocations | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
3 | |||
getFunds | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | /** |
4 | * Alma ILS Driver |
5 | * |
6 | * PHP version 8 |
7 | * |
8 | * Copyright (C) Villanova University 2017. |
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 Demian Katz <demian.katz@villanova.edu> |
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 | |
30 | namespace VuFind\ILS\Driver; |
31 | |
32 | use Laminas\Http\Headers; |
33 | use SimpleXMLElement; |
34 | use VuFind\Exception\ILS as ILSException; |
35 | use VuFind\I18n\TranslatableString; |
36 | use VuFind\I18n\Translator\TranslatorAwareInterface; |
37 | use VuFind\I18n\Translator\TranslatorAwareTrait; |
38 | use VuFind\ILS\Logic\AvailabilityStatusInterface; |
39 | use VuFind\Marc\MarcReader; |
40 | |
41 | use function count; |
42 | use function floatval; |
43 | use function in_array; |
44 | use function is_callable; |
45 | |
46 | /** |
47 | * Alma ILS Driver |
48 | * |
49 | * @category VuFind |
50 | * @package ILS_Drivers |
51 | * @author Demian Katz <demian.katz@villanova.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 | */ |
55 | class Alma extends AbstractBase implements |
56 | \VuFindHttp\HttpServiceAwareInterface, |
57 | \Laminas\Log\LoggerAwareInterface, |
58 | TranslatorAwareInterface |
59 | { |
60 | use \VuFindHttp\HttpServiceAwareTrait; |
61 | use \VuFind\Log\LoggerAwareTrait; |
62 | use \VuFind\Cache\CacheTrait; |
63 | use TranslatorAwareTrait; |
64 | |
65 | /** |
66 | * Alma API base URL. |
67 | * |
68 | * @var string |
69 | */ |
70 | protected $baseUrl; |
71 | |
72 | /** |
73 | * Alma API key. |
74 | * |
75 | * @var string |
76 | */ |
77 | protected $apiKey; |
78 | |
79 | /** |
80 | * Date converter |
81 | * |
82 | * @var \VuFind\Date\Converter |
83 | */ |
84 | protected $dateConverter; |
85 | |
86 | /** |
87 | * Mappings from location type to item status. Overrides any other item status. |
88 | * |
89 | * @var array |
90 | */ |
91 | protected $locationTypeToItemStatus = []; |
92 | |
93 | /** |
94 | * Constructor |
95 | * |
96 | * @param \VuFind\Date\Converter $dateConverter Date converter object |
97 | */ |
98 | public function __construct(\VuFind\Date\Converter $dateConverter) |
99 | { |
100 | $this->dateConverter = $dateConverter; |
101 | } |
102 | |
103 | /** |
104 | * Initialize the driver. |
105 | * |
106 | * Validate configuration and perform all resource-intensive tasks needed to |
107 | * make the driver active. |
108 | * |
109 | * @throws ILSException |
110 | * @return void |
111 | */ |
112 | public function init() |
113 | { |
114 | if (empty($this->config)) { |
115 | throw new ILSException('Configuration needs to be set.'); |
116 | } |
117 | $this->baseUrl = $this->config['Catalog']['apiBaseUrl']; |
118 | $this->apiKey = $this->config['Catalog']['apiKey']; |
119 | |
120 | if (!empty($this->config['Holdings']['locationTypeItemStatus'])) { |
121 | $this->locationTypeToItemStatus |
122 | = $this->config['Holdings']['locationTypeItemStatus']; |
123 | } |
124 | } |
125 | |
126 | /** |
127 | * Make an HTTP request against Alma |
128 | * |
129 | * @param string $path Path to retrieve from API (excluding base |
130 | * URL/API key) |
131 | * @param array $paramsGet Additional GET params |
132 | * @param array $paramsPost Additional POST params |
133 | * @param string $method GET or POST. Default is GET. |
134 | * @param string $rawBody Request body. |
135 | * @param Headers|array $headers Add headers to the call. |
136 | * @param array $allowedErrors HTTP status codes that are not treated as |
137 | * API errors. |
138 | * @param bool $returnStatus Whether to return HTTP status in addition |
139 | * to the response. |
140 | * |
141 | * @throws ILSException |
142 | * @return null|SimpleXMLElement|array |
143 | */ |
144 | protected function makeRequest( |
145 | $path, |
146 | $paramsGet = [], |
147 | $paramsPost = [], |
148 | $method = 'GET', |
149 | $rawBody = null, |
150 | $headers = null, |
151 | $allowedErrors = [], |
152 | $returnStatus = false |
153 | ) { |
154 | // Set some variables |
155 | $url = null; |
156 | $result = null; |
157 | $statusCode = null; |
158 | $returnValue = null; |
159 | $startTime = microtime(true); |
160 | |
161 | try { |
162 | // Set API key if it is not already available in the GET params |
163 | if (!isset($paramsGet['apikey'])) { |
164 | $paramsGet['apikey'] = $this->apiKey; |
165 | } |
166 | |
167 | // Create the API URL |
168 | $url = !str_contains($path, '://') ? $this->baseUrl . $path : $path; |
169 | |
170 | // Create client with API URL |
171 | $client = $this->httpService->createClient($url); |
172 | |
173 | // Set method |
174 | $client->setMethod($method); |
175 | |
176 | // Set timeout |
177 | $timeout = $this->config['Catalog']['http_timeout'] ?? 30; |
178 | $client->setOptions(['timeout' => $timeout]); |
179 | |
180 | // Set other GET parameters (apikey and other URL parameters are used |
181 | // also with e.g. POST requests) |
182 | $client->setParameterGet($paramsGet); |
183 | // Set POST parameters |
184 | if ($method == 'POST') { |
185 | $client->setParameterPost($paramsPost); |
186 | } |
187 | |
188 | // Set body if applicable |
189 | if (isset($rawBody)) { |
190 | $client->setRawBody($rawBody); |
191 | } |
192 | |
193 | // Set headers if applicable |
194 | if (isset($headers)) { |
195 | $client->setHeaders($headers); |
196 | } |
197 | |
198 | // Execute HTTP call |
199 | $result = $client->send(); |
200 | } catch (\Exception $e) { |
201 | $this->logError("$method request '$url' failed: " . $e->getMessage()); |
202 | $this->throwAsIlsException($e); |
203 | } |
204 | |
205 | // Get the HTTP status code and response |
206 | $statusCode = $result->getStatusCode(); |
207 | $answer = $statusCode !== 204 ? $result->getBody() : ''; |
208 | $answer = str_replace('xmlns=', 'ns=', $answer); |
209 | |
210 | $duration = round(microtime(true) - $startTime, 4); |
211 | $urlParams = $client->getRequest()->getQuery()->toString(); |
212 | $fullUrl = $url . (!str_contains($url, '?') ? '?' : '&') . $urlParams; |
213 | $this->debug( |
214 | "[$duration] $method request '$fullUrl' results ($statusCode):\n" |
215 | . $answer |
216 | ); |
217 | |
218 | // Check for error |
219 | if ($result->isServerError()) { |
220 | $this->logError( |
221 | "$method request '$url' failed, HTTP error code: $statusCode" |
222 | ); |
223 | throw new ILSException('HTTP error code: ' . $statusCode, $statusCode); |
224 | } |
225 | |
226 | try { |
227 | $xml = simplexml_load_string($answer); |
228 | } catch (\Exception $e) { |
229 | $this->logError( |
230 | "Could not parse response for $method request '$url': " |
231 | . $e->getMessage() . ". Response was:\n" |
232 | . $result->getHeaders()->toString() |
233 | . "\n\n$answer" |
234 | ); |
235 | $this->throwAsIlsException($e); |
236 | } |
237 | if ($result->isSuccess() || in_array($statusCode, $allowedErrors)) { |
238 | if (!$xml && $result->isServerError()) { |
239 | $error = 'XML is not valid or HTTP error, URL: ' . $url . |
240 | ', HTTP status code: ' . $statusCode; |
241 | $this->logError($error); |
242 | throw new ILSException($error, $statusCode); |
243 | } |
244 | $returnValue = $xml; |
245 | } else { |
246 | $almaErrorMsg = $xml->errorList->error[0]->errorMessage |
247 | ?? '[could not parse error message]'; |
248 | $errorMsg = "Alma error for $method request '$url' (status code" |
249 | . " $statusCode): $almaErrorMsg"; |
250 | $this->logError( |
251 | $errorMsg . '. GET params: ' . $this->varDump($paramsGet) |
252 | . '. POST params: ' . $this->varDump($paramsPost) |
253 | . '. Result body: ' . $result->getBody() |
254 | ); |
255 | throw new ILSException($errorMsg, $statusCode); |
256 | } |
257 | |
258 | return $returnStatus ? [$returnValue, $statusCode] : $returnValue; |
259 | } |
260 | |
261 | /** |
262 | * Given an item, return its availability and status. |
263 | * |
264 | * @param \SimpleXMLElement $item Item data |
265 | * |
266 | * @return array Availability and status |
267 | */ |
268 | protected function getItemAvailabilityAndStatus(\SimpleXMLElement $item): array |
269 | { |
270 | // Check location type to status mappings first since they override |
271 | // everything else: |
272 | $available = null; |
273 | $status = null; |
274 | if ($this->locationTypeToItemStatus) { |
275 | [$available, $status] = $this->getItemStatusFromLocationTypeMap( |
276 | $this->getItemLocationType($item) |
277 | ); |
278 | } |
279 | |
280 | // Normal checks for status if no mapping found above: |
281 | if (null === $status) { |
282 | $status = (string)$item->item_data->base_status[0]->attributes()['desc']; |
283 | $duedate = $item->item_data->due_date |
284 | ? $this->parseDate((string)$item->item_data->due_date) : null; |
285 | if ($duedate && 'Item not in place' === $status) { |
286 | $status = 'Checked Out'; |
287 | } |
288 | |
289 | $processType = (string)($item->item_data->process_type ?? ''); |
290 | if ($processType && 'LOAN' !== $processType) { |
291 | $status = $this->getTranslatableStatusString( |
292 | $item->item_data->process_type |
293 | ); |
294 | } |
295 | } |
296 | |
297 | // Normal check for availability if no mapping found above: |
298 | if (null === $available) { |
299 | $available = (string)$item->item_data->base_status === '1' |
300 | ? AvailabilityStatusInterface::STATUS_AVAILABLE |
301 | : AvailabilityStatusInterface::STATUS_UNAVAILABLE; |
302 | } |
303 | |
304 | return [$available, $status]; |
305 | } |
306 | |
307 | /** |
308 | * Given an item, return its availability and status based on location type |
309 | * mappings. |
310 | * |
311 | * @param string $locationType Location type |
312 | * |
313 | * @return array Availability and status |
314 | */ |
315 | protected function getItemStatusFromLocationTypeMap(string $locationType): array |
316 | { |
317 | if (null === ($setting = $this->locationTypeToItemStatus[$locationType] ?? null)) { |
318 | return [null, null]; |
319 | } |
320 | $parts = explode(':', $setting); |
321 | $available = null; |
322 | $status = new TranslatableString($parts[0], $parts[0]); |
323 | if (isset($parts[1])) { |
324 | switch ($parts[1]) { |
325 | case 'unavailable': |
326 | $available = AvailabilityStatusInterface::STATUS_UNAVAILABLE; |
327 | break; |
328 | case 'uncertain': |
329 | $available = AvailabilityStatusInterface::STATUS_UNCERTAIN; |
330 | break; |
331 | default: |
332 | $available = AvailabilityStatusInterface::STATUS_AVAILABLE; |
333 | break; |
334 | } |
335 | } |
336 | return [$available, $status]; |
337 | } |
338 | |
339 | /** |
340 | * Get Holding |
341 | * |
342 | * This is responsible for retrieving the holding information of a certain |
343 | * record. |
344 | * |
345 | * @param string $id The record id to retrieve the holdings for |
346 | * @param array $patron Patron data |
347 | * @param array $options Additional options |
348 | * |
349 | * @return array On success an array with the key "total" containing the total |
350 | * number of items for the given bib id, and the key "holdings" containing an |
351 | * array of holding information each one with these keys: id, source, |
352 | * availability, status, location, reserve, callnumber, duedate, returnDate, |
353 | * number, barcode, item_notes, item_id, holdings_id, addLink, description |
354 | */ |
355 | public function getHolding($id, $patron = null, array $options = []) |
356 | { |
357 | // Prepare result array with default values. If no API result can be received |
358 | // these will be returned. |
359 | $results = ['total' => 0, 'holdings' => []]; |
360 | |
361 | // Correct copy count in case of paging |
362 | $copyCount = $options['offset'] ?? 0; |
363 | |
364 | // Paging parameters for paginated API call. The "limit" tells the API how |
365 | // many items the call should return at once (e. g. 10). The "offset" defines |
366 | // the range (e. g. get items 30 to 40). With these parameters we are able to |
367 | // use a paginator for paging through many items. |
368 | $apiPagingParams = ''; |
369 | if ($options['itemLimit'] ?? null) { |
370 | $apiPagingParams = '&limit=' . urlencode($options['itemLimit']) |
371 | . '&offset=' . urlencode($options['offset'] ?? 0); |
372 | } |
373 | |
374 | // The path for the API call. We call "ALL" available items, but not at once |
375 | // as a pagination mechanism is used. If paging params are not set for some |
376 | // reason, the first 10 items are called which is the default API behaviour. |
377 | $itemsPath = '/bibs/' . rawurlencode($id) . '/holdings/ALL/items' |
378 | . '?order_by=library,location,enum_a,enum_b&direction=desc' |
379 | . '&expand=due_date' |
380 | . $apiPagingParams; |
381 | |
382 | if ($items = $this->makeRequest($itemsPath)) { |
383 | // Get the total number of items returned from the API call and set it to |
384 | // a class variable. It is then used in VuFind\RecordTab\HoldingsILS for |
385 | // the items paginator. |
386 | $results['total'] = (int)$items->attributes()->total_record_count; |
387 | |
388 | foreach ($items->item as $item) { |
389 | $number = ++$copyCount; |
390 | $holdingId = (string)$item->holding_data->holding_id; |
391 | $itemId = (string)$item->item_data->pid; |
392 | $barcode = (string)$item->item_data->barcode; |
393 | $itemNotes = !empty($item->item_data->public_note) |
394 | ? [(string)$item->item_data->public_note] : null; |
395 | $duedate = $item->item_data->due_date |
396 | ? $this->parseDate((string)$item->item_data->due_date) : null; |
397 | [$available, $status] = $this->getItemAvailabilityAndStatus($item); |
398 | |
399 | $description = null; |
400 | if (!empty($item->item_data->description)) { |
401 | $number = (string)$item->item_data->description; |
402 | $description = (string)$item->item_data->description; |
403 | } |
404 | $callnumber = $item->holding_data->call_number; |
405 | $results['holdings'][] = [ |
406 | 'id' => $id, |
407 | 'source' => 'Solr', |
408 | 'availability' => $available, |
409 | 'status' => $status, |
410 | 'location' => $this->getItemLocation($item), |
411 | 'reserve' => 'N', // TODO: support reserve status |
412 | 'callnumber' => (string)($callnumber->desc ?? $callnumber), |
413 | 'duedate' => $duedate, |
414 | 'returnDate' => false, // TODO: support recent returns |
415 | 'number' => $number, |
416 | 'barcode' => empty($barcode) ? 'n/a' : $barcode, |
417 | 'item_notes' => $itemNotes ?? null, |
418 | 'item_id' => $itemId, |
419 | 'holdings_id' => $holdingId, |
420 | 'holding_id' => $holdingId, // deprecated, retained for backward compatibility |
421 | 'holdtype' => 'auto', |
422 | 'addLink' => $patron ? 'check' : false, |
423 | // For Alma title-level hold requests |
424 | 'description' => $description ?? null, |
425 | ]; |
426 | } |
427 | } |
428 | |
429 | // Fetch also digital and/or electronic inventory if configured |
430 | $types = $this->getInventoryTypes(); |
431 | if (in_array('d_avail', $types) || in_array('e_avail', $types)) { |
432 | // No need for physical items |
433 | $key = array_search('p_avail', $types); |
434 | if (false !== $key) { |
435 | unset($types[$key]); |
436 | } |
437 | $statuses = $this->getStatusesForInventoryTypes((array)$id, $types); |
438 | $electronic = []; |
439 | foreach ($statuses as $record) { |
440 | foreach ($record as $status) { |
441 | $electronic[] = $status; |
442 | } |
443 | } |
444 | $results['electronic_holdings'] = $electronic; |
445 | } |
446 | |
447 | return $results; |
448 | } |
449 | |
450 | /** |
451 | * Check if request is valid |
452 | * |
453 | * This is responsible for determining if an item is requestable |
454 | * |
455 | * @param string $id The record id |
456 | * @param array $data An array of item data |
457 | * @param array $patron An array of patron data |
458 | * |
459 | * @return bool True if request is valid, false if not |
460 | */ |
461 | public function checkRequestIsValid($id, $data, $patron) |
462 | { |
463 | $patronId = $patron['id']; |
464 | $level = $data['level'] ?? 'copy'; |
465 | if ('copy' === $level) { |
466 | // Call the request-options API for the logged-in user; note that holding_id |
467 | // is deprecated but retained for backward compatibility. |
468 | $requestOptionsPath = '/bibs/' . rawurlencode($id) |
469 | . '/holdings/' . rawurlencode($data['holdings_id'] ?? $data['holding_id']) |
470 | . '/items/' . rawurlencode($data['item_id']) . '/request-options?user_id=' |
471 | . urlencode($patronId); |
472 | |
473 | // Make the API request |
474 | $requestOptions = $this->makeRequest($requestOptionsPath); |
475 | } elseif ('title' === $level) { |
476 | $hmac = explode(':', $this->config['Holds']['HMACKeys'] ?? ''); |
477 | if (!in_array('level', $hmac) || !in_array('description', $hmac)) { |
478 | return false; |
479 | } |
480 | // Call the request-options API for the logged-in user |
481 | $requestOptionsPath = '/bibs/' . rawurlencode($id) |
482 | . '/request-options?user_id=' . urlencode($patronId); |
483 | |
484 | // Make the API request |
485 | $requestOptions = $this->makeRequest($requestOptionsPath); |
486 | } else { |
487 | return false; |
488 | } |
489 | |
490 | // Check possible request types from the API answer |
491 | $requestTypes = $requestOptions->xpath( |
492 | '/request_options/request_option//type' |
493 | ); |
494 | foreach ($requestTypes as $requestType) { |
495 | if ('HOLD' === (string)$requestType) { |
496 | return true; |
497 | } |
498 | } |
499 | |
500 | return false; |
501 | } |
502 | |
503 | /** |
504 | * Check for request blocks. |
505 | * |
506 | * @param array $patron The patron array with username and password |
507 | * |
508 | * @return array|boolean An array of block messages or false if there are no |
509 | * blocks |
510 | * @author Michael Birkner |
511 | */ |
512 | public function getRequestBlocks($patron) |
513 | { |
514 | return $this->getAccountBlocks($patron); |
515 | } |
516 | |
517 | /** |
518 | * Check for account blocks in Alma and cache them. |
519 | * |
520 | * @param array $patron The patron array with username and password |
521 | * |
522 | * @return array|boolean An array of block messages or false if there are no |
523 | * blocks |
524 | * @author Michael Birkner |
525 | */ |
526 | public function getAccountBlocks($patron) |
527 | { |
528 | $patronId = $patron['id']; |
529 | $cacheId = 'alma|user|' . $patronId . '|blocks'; |
530 | $cachedBlocks = $this->getCachedData($cacheId); |
531 | if ($cachedBlocks !== null) { |
532 | return $cachedBlocks; |
533 | } |
534 | |
535 | $xml = $this->makeRequest('/users/' . rawurlencode($patronId)); |
536 | if ($xml == null || empty($xml)) { |
537 | return false; |
538 | } |
539 | |
540 | $userBlocks = $xml->user_blocks->user_block; |
541 | if ($userBlocks == null || empty($userBlocks)) { |
542 | return false; |
543 | } |
544 | |
545 | $blocks = []; |
546 | foreach ($userBlocks as $block) { |
547 | $blockStatus = (string)$block->block_status; |
548 | if ($blockStatus === 'ACTIVE') { |
549 | $blockNote = (isset($block->block_note)) |
550 | ? (string)$block->block_note |
551 | : null; |
552 | $blockDesc = (string)$block->block_description->attributes()->desc; |
553 | $blockDesc = ($blockNote != null) |
554 | ? $blockDesc . '. ' . $blockNote |
555 | : $blockDesc; |
556 | $blocks[] = $blockDesc; |
557 | } |
558 | } |
559 | |
560 | if (!empty($blocks)) { |
561 | $this->putCachedData($cacheId, $blocks); |
562 | return $blocks; |
563 | } else { |
564 | $this->putCachedData($cacheId, false); |
565 | return false; |
566 | } |
567 | } |
568 | |
569 | /** |
570 | * Get an Alma fulfillment unit by an Alma location. |
571 | * |
572 | * @param string $locationCode A location code, e. g. "SCI" |
573 | * @param array $fulfillmentUnits An array of fulfillment units with all its |
574 | * locations. |
575 | * |
576 | * @return string|NULL Null if the location was not found or a |
577 | * string specifying the fulfillment unit of |
578 | * the location that was found. |
579 | * @author Michael Birkner |
580 | */ |
581 | protected function getFulfillmentUnitByLocation($locationCode, $fulfillmentUnits) |
582 | { |
583 | foreach ($fulfillmentUnits as $key => $val) { |
584 | if (array_search($locationCode, $val) !== false) { |
585 | return $key; |
586 | } |
587 | } |
588 | return null; |
589 | } |
590 | |
591 | /** |
592 | * Create a user in Alma via API call |
593 | * |
594 | * @param array $formParams The data from the "create new account" form |
595 | * |
596 | * @throws \VuFind\Exception\Auth |
597 | * |
598 | * @return NULL|SimpleXMLElement |
599 | * @author Michael Birkner |
600 | */ |
601 | public function createAlmaUser($formParams) |
602 | { |
603 | // Get config for creating new Alma users from Alma.ini |
604 | $newUserConfig = $this->config['NewUser'] ?? []; |
605 | |
606 | // Check if config params are all set |
607 | $configParams = [ |
608 | 'recordType', 'userGroup', 'preferredLanguage', |
609 | 'accountType', 'status', 'emailType', 'idType', |
610 | ]; |
611 | foreach ($configParams as $configParam) { |
612 | if (empty(trim($newUserConfig[$configParam] ?? ''))) { |
613 | $errorMessage = 'Configuration "' . $configParam . '" is not set ' . |
614 | 'in Alma ini in the [NewUser] section!'; |
615 | $this->logError($errorMessage); |
616 | throw new \VuFind\Exception\Auth($errorMessage); |
617 | } |
618 | } |
619 | |
620 | // Calculate expiry date based on config in Alma.ini |
621 | $expiryDate = new \DateTime('now'); |
622 | if (!empty(trim($newUserConfig['expiryDate'] ?? ''))) { |
623 | try { |
624 | $expiryDate->add( |
625 | new \DateInterval($newUserConfig['expiryDate']) |
626 | ); |
627 | } catch (\Exception $exception) { |
628 | $errorMessage = 'Configuration "expiryDate" in Alma ini (see ' . |
629 | '[NewUser] section) has the wrong format!'; |
630 | $this->logError($errorMessage); |
631 | throw new \VuFind\Exception\Auth($errorMessage); |
632 | } |
633 | } else { |
634 | $expiryDate->add(new \DateInterval('P1Y')); |
635 | } |
636 | |
637 | // Calculate purge date based on config in Alma.ini |
638 | $purgeDate = null; |
639 | if (!empty(trim($newUserConfig['purgeDate'] ?? ''))) { |
640 | try { |
641 | $purgeDate = new \DateTime('now'); |
642 | $purgeDate->add( |
643 | new \DateInterval($newUserConfig['purgeDate']) |
644 | ); |
645 | } catch (\Exception $exception) { |
646 | $errorMessage = 'Configuration "purgeDate" in Alma ini (see ' . |
647 | '[NewUser] section) has the wrong format!'; |
648 | $this->logError($errorMessage); |
649 | throw new \VuFind\Exception\Auth($errorMessage); |
650 | } |
651 | } |
652 | |
653 | // Create user XML for Alma API |
654 | $xml = simplexml_load_string( |
655 | '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' |
656 | . "\n\n<user/>" |
657 | ); |
658 | $xml->addChild('record_type', $newUserConfig['recordType']); |
659 | $xml->addChild('first_name', $formParams['firstname']); |
660 | $xml->addChild('last_name', $formParams['lastname']); |
661 | $xml->addChild('user_group', $newUserConfig['userGroup']); |
662 | $xml->addChild( |
663 | 'preferred_language', |
664 | $newUserConfig['preferredLanguage'] |
665 | ); |
666 | $xml->addChild('account_type', $newUserConfig['accountType']); |
667 | $xml->addChild('status', $newUserConfig['status']); |
668 | $xml->addChild('expiry_date', $expiryDate->format('Y-m-d') . 'Z'); |
669 | if (null !== $purgeDate) { |
670 | $xml->addChild('purge_date', $purgeDate->format('Y-m-d') . 'Z'); |
671 | } |
672 | |
673 | $contactInfo = $xml->addChild('contact_info'); |
674 | $emails = $contactInfo->addChild('emails'); |
675 | $email = $emails->addChild('email'); |
676 | $email->addAttribute('preferred', 'true'); |
677 | $email->addChild('email_address', $formParams['email']); |
678 | $emailTypes = $email->addChild('email_types'); |
679 | $emailTypes->addChild('email_type', $newUserConfig['emailType']); |
680 | |
681 | $userIdentifiers = $xml->addChild('user_identifiers'); |
682 | $userIdentifier = $userIdentifiers->addChild('user_identifier'); |
683 | $userIdentifier->addChild('id_type', $newUserConfig['idType']); |
684 | $userIdentifier->addChild('value', $formParams['username']); |
685 | |
686 | $userXml = $xml->asXML(); |
687 | |
688 | // Create user in Alma |
689 | $almaAnswer = $this->makeRequest( |
690 | '/users', |
691 | [], |
692 | [], |
693 | 'POST', |
694 | $userXml, |
695 | ['Content-Type' => 'application/xml'] |
696 | ); |
697 | |
698 | // Return the XML from Alma on success. On error, an exception is thrown |
699 | // in makeRequest |
700 | return $almaAnswer; |
701 | } |
702 | |
703 | /** |
704 | * Patron Login |
705 | * |
706 | * This is responsible for authenticating a patron against the catalog. |
707 | * |
708 | * @param string $username The patrons barcode or other username. |
709 | * @param string $password The patrons password. |
710 | * |
711 | * @return string[]|NULL |
712 | */ |
713 | public function patronLogin($username, $password) |
714 | { |
715 | $loginMethod = $this->config['Catalog']['loginMethod'] ?? 'vufind'; |
716 | |
717 | $patron = []; |
718 | $patronId = $username; |
719 | if ('email' === $loginMethod) { |
720 | // Try to find the user in Alma by an identifier |
721 | [$response, $status] = $this->makeRequest( |
722 | '/users/' . rawurlencode($username), |
723 | [ |
724 | 'view' => 'full', |
725 | ], |
726 | [], |
727 | 'GET', |
728 | null, |
729 | null, |
730 | [400], |
731 | true |
732 | ); |
733 | if (400 != $status) { |
734 | $patron = [ |
735 | 'id' => (string)$response->primary_id, |
736 | 'cat_username' => trim($username), |
737 | 'email' => $this->getPreferredEmail($response), |
738 | ]; |
739 | } else { |
740 | // Try to find the user in Alma by unique email address |
741 | $getParams = [ |
742 | 'q' => 'email~' . $username, |
743 | ]; |
744 | |
745 | $response = $this->makeRequest( |
746 | '/users/', |
747 | $getParams |
748 | ); |
749 | |
750 | foreach (($response->user ?? []) as $user) { |
751 | if ((string)$user->status !== 'ACTIVE') { |
752 | continue; |
753 | } |
754 | if ($patron) { |
755 | // More than one match, cannot log in by email |
756 | $this->debug( |
757 | "Email $username matches more than one user, cannot" |
758 | . ' login' |
759 | ); |
760 | return null; |
761 | } |
762 | $patron = [ |
763 | 'id' => (string)$user->primary_id, |
764 | 'cat_username' => trim($username), |
765 | 'email' => trim($username), |
766 | ]; |
767 | } |
768 | } |
769 | if (!$patron) { |
770 | return null; |
771 | } |
772 | // Use primary id in further queries |
773 | $patronId = $patron['id']; |
774 | } elseif ('password' === $loginMethod) { |
775 | // Create parameters for API call |
776 | $getParams = [ |
777 | 'user_id_type' => 'all_unique', |
778 | 'op' => 'auth', |
779 | 'password' => $password, |
780 | ]; |
781 | |
782 | // Try to authenticate the user with Alma |
783 | [$response, $status] = $this->makeRequest( |
784 | '/users/' . rawurlencode($username), |
785 | $getParams, |
786 | [], |
787 | 'POST', |
788 | null, |
789 | null, |
790 | [400], |
791 | true |
792 | ); |
793 | if (400 === $status) { |
794 | return null; |
795 | } |
796 | } elseif ('vufind' !== $loginMethod) { |
797 | $this->logError("Invalid login method configured: $loginMethod"); |
798 | throw new ILSException('Invalid login method configured'); |
799 | } |
800 | |
801 | // Create parameters for API call |
802 | $getParams = [ |
803 | 'user_id_type' => 'all_unique', |
804 | 'view' => 'full', |
805 | 'expand' => 'none', |
806 | ]; |
807 | |
808 | // Check for patron in Alma |
809 | [$response, $status] = $this->makeRequest( |
810 | '/users/' . rawurlencode($patronId), |
811 | $getParams, |
812 | [], |
813 | 'GET', |
814 | null, |
815 | null, |
816 | [400], |
817 | true |
818 | ); |
819 | |
820 | if ($status != 400 && $response !== null) { |
821 | // We may already have some information, so just fill the gaps |
822 | $patron['id'] = (string)$response->primary_id; |
823 | $patron['cat_username'] = trim($username); |
824 | $patron['cat_password'] = trim($password); |
825 | $patron['firstname'] = (string)$response->first_name ?? ''; |
826 | $patron['lastname'] = (string)$response->last_name ?? ''; |
827 | $patron['email'] = $this->getPreferredEmail($response); |
828 | return $patron; |
829 | } |
830 | |
831 | return null; |
832 | } |
833 | |
834 | /** |
835 | * Get Patron Profile |
836 | * |
837 | * This is responsible for retrieving the profile for a specific patron. |
838 | * |
839 | * @param array $patron The patron array |
840 | * |
841 | * @return array Array of the patron's profile data on success. |
842 | */ |
843 | public function getMyProfile($patron) |
844 | { |
845 | $patronId = $patron['id']; |
846 | $xml = $this->makeRequest('/users/' . rawurlencode($patronId)); |
847 | if (empty($xml)) { |
848 | return []; |
849 | } |
850 | $profile = [ |
851 | 'firstname' => (isset($xml->first_name)) |
852 | ? (string)$xml->first_name |
853 | : null, |
854 | 'lastname' => (isset($xml->last_name)) |
855 | ? (string)$xml->last_name |
856 | : null, |
857 | 'group' => isset($xml->user_group) |
858 | ? $this->getTranslatableString($xml->user_group) |
859 | : null, |
860 | 'group_code' => (isset($xml->user_group)) |
861 | ? (string)$xml->user_group |
862 | : null, |
863 | ]; |
864 | $contact = $xml->contact_info; |
865 | if ($contact) { |
866 | if ($contact->addresses) { |
867 | $address = $contact->addresses[0]->address; |
868 | $profile['address1'] = (isset($address->line1)) |
869 | ? (string)$address->line1 |
870 | : null; |
871 | $profile['address2'] = (isset($address->line2)) |
872 | ? (string)$address->line2 |
873 | : null; |
874 | $profile['address3'] = (isset($address->line3)) |
875 | ? (string)$address->line3 |
876 | : null; |
877 | $profile['zip'] = (isset($address->postal_code)) |
878 | ? (string)$address->postal_code |
879 | : null; |
880 | $profile['city'] = (isset($address->city)) |
881 | ? (string)$address->city |
882 | : null; |
883 | $profile['country'] = (isset($address->country)) |
884 | ? (string)$address->country |
885 | : null; |
886 | } |
887 | if ($contact->phones) { |
888 | $profile['phone'] = (isset($contact->phones[0]->phone->phone_number)) |
889 | ? (string)$contact->phones[0]->phone->phone_number |
890 | : null; |
891 | } |
892 | $profile['email'] = $this->getPreferredEmail($xml); |
893 | } |
894 | if ($xml->birth_date) { |
895 | // Drop any time zone designator from the date: |
896 | $profile['birthdate'] = substr((string)$xml->birth_date, 0, 10); |
897 | } |
898 | |
899 | // Cache the user group code |
900 | $cacheId = 'alma|user|' . $patronId . '|group_code'; |
901 | $this->putCachedData($cacheId, $profile['group_code'] ?? null); |
902 | |
903 | return $profile; |
904 | } |
905 | |
906 | /** |
907 | * Get Patron Fines |
908 | * |
909 | * This is responsible for retrieving all fines by a specific patron. |
910 | * |
911 | * @param array $patron The patron array from patronLogin |
912 | * |
913 | * @return mixed Array of the patron's fines on success. |
914 | */ |
915 | public function getMyFines($patron) |
916 | { |
917 | $xml = $this->makeRequest( |
918 | '/users/' . rawurlencode($patron['id']) . '/fees' |
919 | ); |
920 | $fineList = []; |
921 | foreach ($xml as $fee) { |
922 | $created = (string)$fee->creation_time; |
923 | $checkout = (string)$fee->status_time; |
924 | $fineList[] = [ |
925 | 'title' => (string)($fee->title ?? ''), |
926 | 'amount' => round(floatval($fee->original_amount) * 100), |
927 | 'balance' => round(floatval($fee->balance) * 100), |
928 | 'createdate' => $this->parseDate($created, true), |
929 | 'checkout' => $this->parseDate($checkout, true), |
930 | 'fine' => $this->getTranslatableString($fee->type), |
931 | ]; |
932 | } |
933 | return $fineList; |
934 | } |
935 | |
936 | /** |
937 | * Get Patron Holds |
938 | * |
939 | * This is responsible for retrieving all holds by a specific patron. |
940 | * |
941 | * @param array $patron The patron array from patronLogin |
942 | * |
943 | * @return mixed Array of the patron's holds on success. |
944 | * |
945 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
946 | */ |
947 | public function getMyHolds($patron) |
948 | { |
949 | $holdList = []; |
950 | $offset = 0; |
951 | $totalCount = 1; |
952 | $allowCancelingAvailableRequests |
953 | = $this->config['Holds']['allowCancelingAvailableRequests'] ?? true; |
954 | while ($offset < $totalCount) { |
955 | $xml = $this->makeRequest( |
956 | '/users/' . rawurlencode($patron['id']) . '/requests', |
957 | ['request_type' => 'HOLD', 'offset' => $offset, 'limit' => 100] |
958 | ); |
959 | $offset += 100; |
960 | $totalCount = (int)$xml->attributes()->{'total_record_count'}; |
961 | foreach ($xml as $request) { |
962 | $lastInterestDate = $request->last_interest_date |
963 | ? $this->dateConverter->convertToDisplayDate( |
964 | 'Y-m-dT', |
965 | (string)$request->last_interest_date |
966 | ) : null; |
967 | $available = (string)$request->request_status === 'On Hold Shelf'; |
968 | $lastPickupDate = null; |
969 | if ($available) { |
970 | $lastPickupDate = $request->expiry_date |
971 | ? $this->dateConverter->convertToDisplayDate( |
972 | 'Y-m-dT', |
973 | (string)$request->expiry_date |
974 | ) : null; |
975 | $lastInterestDate = null; |
976 | } |
977 | $requestStatus = (string)$request->request_status; |
978 | $updateDetails = (!$available || $allowCancelingAvailableRequests) |
979 | ? (string)$request->request_id : ''; |
980 | |
981 | $hold = [ |
982 | 'create' => $this->parseDate((string)$request->request_time), |
983 | 'expire' => $lastInterestDate, |
984 | 'id' => (string)($request->mms_id ?? ''), |
985 | 'reqnum' => (string)$request->request_id, |
986 | 'available' => $available, |
987 | 'last_pickup_date' => $lastPickupDate, |
988 | 'item_id' => (string)$request->request_id, |
989 | 'location' => (string)$request->pickup_location, |
990 | 'processed' => $request->item_policy === 'InterlibraryLoan' |
991 | && $requestStatus !== 'Not Started', |
992 | 'title' => (string)$request->title, |
993 | 'cancel_details' => $updateDetails, |
994 | 'updateDetails' => $updateDetails, |
995 | ]; |
996 | if (!$available) { |
997 | $hold['position'] = 'In Process' === $requestStatus |
998 | ? $this->translate('hold_in_process') |
999 | : (int)($request->place_in_queue ?? 1); |
1000 | } |
1001 | |
1002 | $holdList[] = $hold; |
1003 | } |
1004 | } |
1005 | return $holdList; |
1006 | } |
1007 | |
1008 | /** |
1009 | * Cancel hold requests. |
1010 | * |
1011 | * @param array $cancelDetails An associative array with two keys: patron |
1012 | * (array returned by the driver's |
1013 | * patronLogin method) and details (an array |
1014 | * of strings returned in holds' cancel_details |
1015 | * field. |
1016 | * |
1017 | * @return array Associative array containing with keys 'count' |
1018 | * (number of items successfully cancelled) and |
1019 | * 'items' (array of successful cancellations). |
1020 | */ |
1021 | public function cancelHolds($cancelDetails) |
1022 | { |
1023 | $returnArray = []; |
1024 | $patronId = $cancelDetails['patron']['id']; |
1025 | $count = 0; |
1026 | |
1027 | foreach ($cancelDetails['details'] as $requestId) { |
1028 | $item = []; |
1029 | try { |
1030 | // Delete the request in Alma |
1031 | $apiResult = $this->makeRequest( |
1032 | $this->baseUrl . |
1033 | '/users/' . rawurlencode($patronId) . |
1034 | '/requests/' . rawurlencode($requestId), |
1035 | ['reason' => 'CancelledAtPatronRequest'], |
1036 | [], |
1037 | 'DELETE' |
1038 | ); |
1039 | |
1040 | // Adding to "count" variable and setting values to return array |
1041 | $count++; |
1042 | $item[$requestId] = [ |
1043 | 'success' => true, |
1044 | 'status' => 'hold_cancel_success', |
1045 | ]; |
1046 | } catch (ILSException $e) { |
1047 | if (isset($apiResult['xml'])) { |
1048 | $almaErrorCode = $apiResult['xml']->errorList->error->errorCode; |
1049 | $sysMessage = $apiResult['xml']->errorList->error->errorMessage; |
1050 | } else { |
1051 | $almaErrorCode = 'No error code available'; |
1052 | $sysMessage = 'HTTP status code: ' . |
1053 | ($e->getCode() ?? 'Code not available'); |
1054 | } |
1055 | $item[$requestId] = [ |
1056 | 'success' => false, |
1057 | 'status' => 'hold_cancel_fail', |
1058 | 'sysMessage' => $sysMessage . '. ' . |
1059 | 'Alma request ID: ' . $requestId . '. ' . |
1060 | 'Alma error code: ' . $almaErrorCode, |
1061 | ]; |
1062 | } |
1063 | |
1064 | $returnArray['items'] = $item; |
1065 | } |
1066 | |
1067 | $returnArray['count'] = $count; |
1068 | |
1069 | return $returnArray; |
1070 | } |
1071 | |
1072 | /** |
1073 | * Update holds |
1074 | * |
1075 | * This is responsible for changing the status of hold requests |
1076 | * |
1077 | * @param array $holdsDetails The details identifying the holds |
1078 | * @param array $fields An associative array of fields to be updated |
1079 | * @param array $patron Patron array |
1080 | * |
1081 | * @return array Associative array of the results |
1082 | */ |
1083 | public function updateHolds( |
1084 | array $holdsDetails, |
1085 | array $fields, |
1086 | array $patron |
1087 | ): array { |
1088 | $results = []; |
1089 | $patronId = $patron['id']; |
1090 | foreach ($holdsDetails as $requestId) { |
1091 | $requestUrl = $this->baseUrl . '/users/' . rawurlencode($patronId) |
1092 | . '/requests/' . rawurlencode($requestId); |
1093 | $requestDetails = $this->makeRequest($requestUrl); |
1094 | |
1095 | if (isset($fields['pickUpLocation'])) { |
1096 | $requestDetails->pickup_location_library = $fields['pickUpLocation']; |
1097 | } |
1098 | [$result, $status] = $this->makeRequest( |
1099 | $requestUrl, |
1100 | [], |
1101 | [], |
1102 | 'PUT', |
1103 | $requestDetails->asXML(), |
1104 | ['Content-Type' => 'application/xml'], |
1105 | [400], |
1106 | true |
1107 | ); |
1108 | if (200 != $status) { |
1109 | $error = $result->errorList->error[0]->errorMessage |
1110 | ?? 'hold_error_fail'; |
1111 | $results[$requestId] = [ |
1112 | 'success' => false, |
1113 | 'status' => (string)$error, |
1114 | ]; |
1115 | } else { |
1116 | $results[$requestId] = [ |
1117 | 'success' => true, |
1118 | ]; |
1119 | } |
1120 | } |
1121 | |
1122 | return $results; |
1123 | } |
1124 | |
1125 | /** |
1126 | * Get Patron Storage Retrieval Requests |
1127 | * |
1128 | * This is responsible for retrieving all call slips by a specific patron. |
1129 | * |
1130 | * @param array $patron The patron array from patronLogin |
1131 | * |
1132 | * @return mixed Array of the patron's holds |
1133 | * |
1134 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
1135 | */ |
1136 | public function getMyStorageRetrievalRequests($patron) |
1137 | { |
1138 | $xml = $this->makeRequest( |
1139 | '/users/' . rawurlencode($patron['id']) . '/requests', |
1140 | ['request_type' => 'MOVE'] |
1141 | ); |
1142 | $holdList = []; |
1143 | for ($i = 0; $i < count($xml->user_requests); $i++) { |
1144 | $request = $xml->user_requests[$i]; |
1145 | if ( |
1146 | !isset($request->item_policy) |
1147 | || $request->item_policy !== 'Archive' |
1148 | ) { |
1149 | continue; |
1150 | } |
1151 | $holdList[] = [ |
1152 | 'create' => $request->request_date, |
1153 | 'expire' => $request->last_interest_date, |
1154 | 'id' => $request->request_id, |
1155 | 'in_transit' => $request->request_status !== 'IN_PROCESS', |
1156 | 'item_id' => $request->mms_id, |
1157 | 'location' => $request->pickup_location, |
1158 | 'processed' => $request->item_policy === 'InterlibraryLoan' |
1159 | && $request->request_status !== 'NOT_STARTED', |
1160 | 'title' => $request->title, |
1161 | ]; |
1162 | } |
1163 | return $holdList; |
1164 | } |
1165 | |
1166 | /** |
1167 | * Get Patron ILL Requests |
1168 | * |
1169 | * This is responsible for retrieving all ILL requests by a specific patron. |
1170 | * |
1171 | * @param array $patron The patron array from patronLogin |
1172 | * |
1173 | * @return mixed Array of the patron's ILL requests |
1174 | * |
1175 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
1176 | */ |
1177 | public function getMyILLRequests($patron) |
1178 | { |
1179 | $xml = $this->makeRequest( |
1180 | '/users/' . rawurlencode($patron['id']) . '/requests', |
1181 | ['request_type' => 'MOVE'] |
1182 | ); |
1183 | $holdList = []; |
1184 | for ($i = 0; $i < count($xml->user_requests); $i++) { |
1185 | $request = $xml->user_requests[$i]; |
1186 | if ( |
1187 | !isset($request->item_policy) |
1188 | || $request->item_policy !== 'InterlibraryLoan' |
1189 | ) { |
1190 | continue; |
1191 | } |
1192 | $holdList[] = [ |
1193 | 'create' => $request->request_date, |
1194 | 'expire' => $request->last_interest_date, |
1195 | 'id' => $request->request_id, |
1196 | 'in_transit' => $request->request_status !== 'IN_PROCESS', |
1197 | 'item_id' => $request->mms_id, |
1198 | 'location' => $request->pickup_location, |
1199 | 'processed' => $request->item_policy === 'InterlibraryLoan' |
1200 | && $request->request_status !== 'NOT_STARTED', |
1201 | 'title' => $request->title, |
1202 | ]; |
1203 | } |
1204 | return $holdList; |
1205 | } |
1206 | |
1207 | /** |
1208 | * Get transactions of the current patron. |
1209 | * |
1210 | * @param array $patron The patron array from patronLogin |
1211 | * @param array $params Parameters |
1212 | * |
1213 | * @return array Transaction information as array. |
1214 | * |
1215 | * @author Michael Birkner |
1216 | */ |
1217 | public function getMyTransactions($patron, $params = []) |
1218 | { |
1219 | // Defining the return value |
1220 | $returnArray = []; |
1221 | |
1222 | // Get the patron id |
1223 | $patronId = $patron['id']; |
1224 | |
1225 | // Create a timestamp for calculating the due / overdue status |
1226 | $nowTS = time(); |
1227 | |
1228 | $sort = explode( |
1229 | ' ', |
1230 | !empty($params['sort']) ? $params['sort'] : 'checkout desc', |
1231 | 2 |
1232 | ); |
1233 | if ($sort[0] == 'checkout') { |
1234 | $sortKey = 'loan_date'; |
1235 | } elseif ($sort[0] == 'title') { |
1236 | $sortKey = 'title'; |
1237 | } else { |
1238 | $sortKey = 'due_date'; |
1239 | } |
1240 | $direction = (isset($sort[1]) && 'desc' === $sort[1]) ? 'DESC' : 'ASC'; |
1241 | |
1242 | $pageSize = $params['limit'] ?? 50; |
1243 | $params = [ |
1244 | 'limit' => $pageSize, |
1245 | 'offset' => isset($params['page']) |
1246 | ? ($params['page'] - 1) * $pageSize : 0, |
1247 | 'order_by' => $sortKey, |
1248 | 'direction' => $direction, |
1249 | 'expand' => 'renewable', |
1250 | ]; |
1251 | |
1252 | // Get user loans from Alma API |
1253 | $apiResult = $this->makeRequest( |
1254 | '/users/' . rawurlencode($patronId) . '/loans', |
1255 | $params |
1256 | ); |
1257 | |
1258 | // If there is an API result, process it |
1259 | $totalCount = 0; |
1260 | if ($apiResult) { |
1261 | $totalCount = $apiResult->attributes()->total_record_count; |
1262 | // Iterate over all item loans |
1263 | foreach ($apiResult->item_loan as $itemLoan) { |
1264 | $loan['duedate'] = $this->parseDate( |
1265 | (string)$itemLoan->due_date, |
1266 | true |
1267 | ); |
1268 | //$loan['dueTime'] = ; |
1269 | $loan['dueStatus'] = null; // Calculated below |
1270 | $loan['id'] = (string)$itemLoan->mms_id; |
1271 | //$loan['source'] = 'Solr'; |
1272 | $loan['barcode'] = (string)$itemLoan->item_barcode; |
1273 | //$loan['renew'] = ; |
1274 | //$loan['renewLimit'] = ; |
1275 | //$loan['request'] = ; |
1276 | //$loan['volume'] = ; |
1277 | $loan['publication_year'] = (string)$itemLoan->publication_year; |
1278 | $loan['renewable'] |
1279 | = (strtolower((string)$itemLoan->renewable) == 'true') |
1280 | ? true |
1281 | : false; |
1282 | //$loan['message'] = ; |
1283 | $loan['title'] = (string)$itemLoan->title; |
1284 | $loan['item_id'] = (string)$itemLoan->loan_id; |
1285 | $loan['institution_name'] |
1286 | = $this->getTranslatableString($itemLoan->library); |
1287 | //$loan['isbn'] = ; |
1288 | //$loan['issn'] = ; |
1289 | //$loan['oclc'] = ; |
1290 | //$loan['upc'] = ; |
1291 | $loan['borrowingLocation'] |
1292 | = $this->getTranslatableString($itemLoan->circ_desk); |
1293 | |
1294 | // Calculate due status |
1295 | $dueDateTS = strtotime($loan['duedate']); |
1296 | if ($nowTS > $dueDateTS) { |
1297 | // Loan is overdue |
1298 | $loan['dueStatus'] = 'overdue'; |
1299 | } elseif (($dueDateTS - $nowTS) < 86400) { |
1300 | // Due date within one day |
1301 | $loan['dueStatus'] = 'due'; |
1302 | } |
1303 | |
1304 | $returnArray[] = $loan; |
1305 | } |
1306 | } |
1307 | |
1308 | return [ |
1309 | 'count' => $totalCount, |
1310 | 'records' => $returnArray, |
1311 | ]; |
1312 | } |
1313 | |
1314 | /** |
1315 | * Get Alma loan IDs for use in renewMyItems. |
1316 | * |
1317 | * @param array $checkOutDetails An array from getMyTransactions |
1318 | * |
1319 | * @return string The Alma loan ID for this loan |
1320 | * |
1321 | * @author Michael Birkner |
1322 | */ |
1323 | public function getRenewDetails($checkOutDetails) |
1324 | { |
1325 | $loanId = $checkOutDetails['item_id']; |
1326 | return $loanId; |
1327 | } |
1328 | |
1329 | /** |
1330 | * Renew loans via Alma API. |
1331 | * |
1332 | * @param array $renewDetails An array with the IDs of the loans returned by |
1333 | * getRenewDetails and the patron information |
1334 | * returned by patronLogin. |
1335 | * |
1336 | * @return array[] An array with the renewal details and a success or error |
1337 | * message. |
1338 | * |
1339 | * @author Michael Birkner |
1340 | */ |
1341 | public function renewMyItems($renewDetails) |
1342 | { |
1343 | $returnArray = []; |
1344 | $patronId = $renewDetails['patron']['id']; |
1345 | |
1346 | foreach ($renewDetails['details'] as $loanId) { |
1347 | // Create an empty array that holds the information for a renewal |
1348 | $renewal = []; |
1349 | |
1350 | try { |
1351 | // POST the renewals to Alma |
1352 | $apiResult = $this->makeRequest( |
1353 | '/users/' . rawurlencode($patronId) . '/loans/' |
1354 | . rawurlencode($loanId) . '/?op=renew', |
1355 | [], |
1356 | [], |
1357 | 'POST' |
1358 | ); |
1359 | |
1360 | // Add information to the renewal array |
1361 | $renewal = [ |
1362 | 'success' => true, |
1363 | 'new_date' => $this->parseDate( |
1364 | (string)$apiResult->due_date, |
1365 | true |
1366 | ), |
1367 | 'item_id' => (string)$apiResult->loan_id, |
1368 | 'sysMessage' => 'renew_success', |
1369 | ]; |
1370 | |
1371 | // Add the renewal to the return array |
1372 | $returnArray['details'][$loanId] = $renewal; |
1373 | } catch (ILSException $ilsEx) { |
1374 | // Add the empty renewal array to the return array |
1375 | $returnArray['details'][$loanId] = [ |
1376 | 'success' => false, |
1377 | ]; |
1378 | } |
1379 | } |
1380 | |
1381 | return $returnArray; |
1382 | } |
1383 | |
1384 | /** |
1385 | * Get Status |
1386 | * |
1387 | * This is responsible for retrieving the status information of a certain |
1388 | * record. |
1389 | * |
1390 | * @param string $id The record id to retrieve the holdings for |
1391 | * |
1392 | * @return mixed On success, an associative array with the following keys: |
1393 | * id, availability (boolean), status, location, reserve, callnumber. |
1394 | */ |
1395 | public function getStatus($id) |
1396 | { |
1397 | $idList = [$id]; |
1398 | $status = $this->getStatuses($idList); |
1399 | return current($status); |
1400 | } |
1401 | |
1402 | /** |
1403 | * Get Statuses |
1404 | * |
1405 | * This is responsible for retrieving the status information for a |
1406 | * collection of records. |
1407 | * |
1408 | * @param array $ids The array of record ids to retrieve the status for |
1409 | * |
1410 | * @return array An array of getStatus() return values on success. |
1411 | */ |
1412 | public function getStatuses($ids) |
1413 | { |
1414 | return $this->getStatusesForInventoryTypes($ids, $this->getInventoryTypes()); |
1415 | } |
1416 | |
1417 | /** |
1418 | * Get Purchase History |
1419 | * |
1420 | * This is responsible for retrieving the acquisitions history data for the |
1421 | * specific record (usually recently received issues of a serial). |
1422 | * |
1423 | * @param string $id The record id to retrieve the info for |
1424 | * |
1425 | * @return array An array with the acquisitions data on success. |
1426 | */ |
1427 | public function getPurchaseHistory($id) |
1428 | { |
1429 | // TODO: Alma getPurchaseHistory |
1430 | return []; |
1431 | } |
1432 | |
1433 | /** |
1434 | * Public Function which retrieves renew, hold and cancel settings from the |
1435 | * driver ini file. |
1436 | * |
1437 | * @param string $function The name of the feature to be checked |
1438 | * @param array $params Optional feature-specific parameters (array) |
1439 | * |
1440 | * @return array An array with key-value pairs. |
1441 | * |
1442 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
1443 | */ |
1444 | public function getConfig($function, $params = []) |
1445 | { |
1446 | if ($function == 'patronLogin') { |
1447 | return [ |
1448 | 'loginMethod' => $this->config['Catalog']['loginMethod'] ?? 'vufind', |
1449 | ]; |
1450 | } |
1451 | if (isset($this->config[$function])) { |
1452 | $functionConfig = $this->config[$function]; |
1453 | |
1454 | // Set default value for "itemLimit" in Alma driver |
1455 | if ($function === 'Holdings') { |
1456 | // Use itemLimit in Holds as fallback for backward compatibility |
1457 | $functionConfig['itemLimit'] = ($functionConfig['itemLimit'] |
1458 | ?? $this->config['Holds']['itemLimit'] |
1459 | ?? 10) ?: 10; |
1460 | } |
1461 | } elseif ('getMyTransactions' === $function) { |
1462 | $functionConfig = [ |
1463 | 'max_results' => 100, |
1464 | 'sort' => [ |
1465 | 'checkout desc' => 'sort_checkout_date_desc', |
1466 | 'checkout asc' => 'sort_checkout_date_asc', |
1467 | 'due desc' => 'sort_due_date_desc', |
1468 | 'due asc' => 'sort_due_date_asc', |
1469 | 'title asc' => 'sort_title', |
1470 | ], |
1471 | 'default_sort' => 'due asc', |
1472 | ]; |
1473 | } else { |
1474 | $functionConfig = false; |
1475 | } |
1476 | |
1477 | return $functionConfig; |
1478 | } |
1479 | |
1480 | /** |
1481 | * Place a hold request via Alma API. This could be a title level request or |
1482 | * an item level request. |
1483 | * |
1484 | * @param array $holdDetails An associative array w/ atleast patron and item_id |
1485 | * |
1486 | * @return array success: bool, sysMessage: string |
1487 | * |
1488 | * @link https://developers.exlibrisgroup.com/alma/apis/bibs |
1489 | */ |
1490 | public function placeHold($holdDetails) |
1491 | { |
1492 | // Check for title or item level request |
1493 | $level = $holdDetails['level'] ?? 'item'; |
1494 | |
1495 | // Get information that is valid for both, item level requests and title |
1496 | // level requests. |
1497 | $mmsId = $holdDetails['id']; |
1498 | // The holding_id value is deprecated but retained for back-compatibility |
1499 | $holId = $holdDetails['holdings_id'] ?? $holdDetails['holding_id']; |
1500 | $itmId = $holdDetails['item_id']; |
1501 | $patronId = $holdDetails['patron']['id']; |
1502 | $pickupLocation = $holdDetails['pickUpLocation'] ?? null; |
1503 | $comment = $holdDetails['comment'] ?? null; |
1504 | $requiredBy = (isset($holdDetails['requiredBy'])) |
1505 | ? $this->dateConverter->convertFromDisplayDate( |
1506 | 'Y-m-d', |
1507 | $holdDetails['requiredBy'] |
1508 | ) . 'Z' |
1509 | : null; |
1510 | |
1511 | // Create body for API request |
1512 | $body = []; |
1513 | $body['request_type'] = 'HOLD'; |
1514 | $body['pickup_location_type'] = 'LIBRARY'; |
1515 | $body['pickup_location_library'] = $pickupLocation; |
1516 | $body['comment'] = $comment; |
1517 | $body['last_interest_date'] = $requiredBy; |
1518 | |
1519 | // Remove "null" values from body array |
1520 | $body = array_filter($body); |
1521 | |
1522 | // Check if we have a title level request or an item level request |
1523 | if ($level === 'title') { |
1524 | // Add description if we have one for title level requests as Alma |
1525 | // needs it under certain circumstances. See: https://developers. |
1526 | // exlibrisgroup.com/alma/apis/xsd/rest_user_request.xsd?tags=POST |
1527 | $description = isset($holdDetails['description']) ?? null; |
1528 | if ($description) { |
1529 | $body['description'] = $description; |
1530 | } |
1531 | |
1532 | // Create HTTP client with Alma API URL for title level requests |
1533 | $client = $this->httpService->createClient( |
1534 | $this->baseUrl . '/bibs/' . rawurlencode($mmsId) |
1535 | . '/requests?apikey=' . urlencode($this->apiKey) |
1536 | . '&user_id=' . urlencode($patronId) |
1537 | . '&format=json' |
1538 | ); |
1539 | } else { |
1540 | // Create HTTP client with Alma API URL for item level requests |
1541 | $client = $this->httpService->createClient( |
1542 | $this->baseUrl . '/bibs/' . rawurlencode($mmsId) |
1543 | . '/holdings/' . rawurlencode($holId) |
1544 | . '/items/' . rawurlencode($itmId) |
1545 | . '/requests?apikey=' . urlencode($this->apiKey) |
1546 | . '&user_id=' . urlencode($patronId) |
1547 | . '&format=json' |
1548 | ); |
1549 | } |
1550 | |
1551 | // Set headers |
1552 | $client->setHeaders( |
1553 | [ |
1554 | 'Content-type: application/json', |
1555 | 'Accept: application/json', |
1556 | ] |
1557 | ); |
1558 | |
1559 | // Set HTTP method |
1560 | $client->setMethod(\Laminas\Http\Request::METHOD_POST); |
1561 | |
1562 | // Set body |
1563 | $client->setRawBody(json_encode($body)); |
1564 | |
1565 | // Send API call and get response |
1566 | $response = $client->send(); |
1567 | |
1568 | // Check for success |
1569 | if ($response->isSuccess()) { |
1570 | return ['success' => true]; |
1571 | } else { |
1572 | $url = $client->getRequest()->getUriString(); |
1573 | $statusCode = $response->getStatusCode(); |
1574 | $this->logError( |
1575 | "Alma error for hold POST request '$url' (status code $statusCode): " |
1576 | . $response->getBody() |
1577 | ); |
1578 | } |
1579 | |
1580 | // Get error message |
1581 | $error = json_decode($response->getBody()); |
1582 | if (!$error) { |
1583 | $error = simplexml_load_string($response->getBody()); |
1584 | } |
1585 | |
1586 | return [ |
1587 | 'success' => false, |
1588 | 'sysMessage' => $error->errorList->error[0]->errorMessage |
1589 | ?? 'hold_error_fail', |
1590 | ]; |
1591 | } |
1592 | |
1593 | /** |
1594 | * Get Pick Up Locations |
1595 | * |
1596 | * This is responsible get a list of valid library locations for holds / recall |
1597 | * retrieval |
1598 | * |
1599 | * @param array $patron Patron information returned by the patronLogin |
1600 | * method. |
1601 | * @param array $holdDetails Optional array, only passed in when getting a list |
1602 | * in the context of placing or editing a hold. When placing a hold, it contains |
1603 | * most of the same values passed to placeHold, minus the patron data. When |
1604 | * editing a hold it contains all the hold information returned by getMyHolds. |
1605 | * May be used to limit the pickup options or may be ignored. The driver must |
1606 | * not add new options to the return array based on this data or other areas of |
1607 | * VuFind may behave incorrectly. |
1608 | * |
1609 | * @return array An array of associative arrays with locationID and |
1610 | * locationDisplay keys |
1611 | * |
1612 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
1613 | */ |
1614 | public function getPickupLocations($patron, $holdDetails = null) |
1615 | { |
1616 | $xml = $this->makeRequest('/conf/libraries'); |
1617 | $libraries = []; |
1618 | foreach ($xml as $library) { |
1619 | $libraries[] = [ |
1620 | 'locationID' => (string)$library->code, |
1621 | 'locationDisplay' => (string)$library->name, |
1622 | ]; |
1623 | } |
1624 | return $libraries; |
1625 | } |
1626 | |
1627 | /** |
1628 | * Request from /courses. |
1629 | * |
1630 | * @return array with key = course ID, value = course name |
1631 | */ |
1632 | public function getCourses() |
1633 | { |
1634 | // https://developers.exlibrisgroup.com/alma/apis/courses |
1635 | // GET /almaws/v1/courses |
1636 | $xml = $this->makeRequest('/courses'); |
1637 | $courses = []; |
1638 | foreach ($xml as $course) { |
1639 | $courses[$course->id] = $course->name; |
1640 | } |
1641 | return $courses; |
1642 | } |
1643 | |
1644 | /** |
1645 | * Get reserves by course |
1646 | * |
1647 | * @param string $courseID Value from getCourses |
1648 | * @param string $instructorID Value from getInstructors (not used yet) |
1649 | * @param string $departmentID Value from getDepartments (not used yet) |
1650 | * |
1651 | * @return array With key BIB_ID - The record ID of the current reserve item. |
1652 | * Not currently used: |
1653 | * DISPLAY_CALL_NO, AUTHOR, TITLE, PUBLISHER, PUBLISHER_DATE |
1654 | * |
1655 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
1656 | */ |
1657 | public function findReserves($courseID, $instructorID, $departmentID) |
1658 | { |
1659 | // https://developers.exlibrisgroup.com/alma/apis/courses |
1660 | // GET /almaws/v1/courses/{course_id}/reading-lists |
1661 | $listsBase = '/courses/' . rawurlencode($courseID) . '/reading-lists'; |
1662 | $xml = $this->makeRequest($listsBase); |
1663 | $reserves = []; |
1664 | foreach ($xml as $list) { |
1665 | $listId = $list->id; |
1666 | $listXML = $this->makeRequest( |
1667 | $listsBase . '/' . rawurlencode($listId) . '/citations' |
1668 | ); |
1669 | foreach ($listXML as $citation) { |
1670 | $reserves[$citation->id] = $citation->metadata; |
1671 | } |
1672 | } |
1673 | return $reserves; |
1674 | } |
1675 | |
1676 | /** |
1677 | * Parse a date. |
1678 | * |
1679 | * @param string $date Date to parse |
1680 | * @param boolean $withTime Add time to return if available? |
1681 | * |
1682 | * @return string |
1683 | */ |
1684 | public function parseDate($date, $withTime = false) |
1685 | { |
1686 | // Remove trailing Z from end of date |
1687 | // e.g. from Alma we get dates like 2012-07-13Z without time, which is wrong) |
1688 | if (!str_contains($date, 'T') && str_ends_with($date, 'Z')) { |
1689 | $date = substr($date, 0, -1); |
1690 | } |
1691 | |
1692 | $compactDate = '/^[0-9]{8}$/'; // e. g. 20120725 |
1693 | $euroName = "/^[0-9]+\/[A-Za-z]{3}\/[0-9]{4}$/"; // e. g. 13/jan/2012 |
1694 | $euro = "/^[0-9]+\/[0-9]+\/[0-9]{4}$/"; // e. g. 13/7/2012 |
1695 | $euroPad = "/^[0-9]{1,2}\/[0-9]{1,2}\/[0-9]{2,4}$/"; // e. g. 13/07/2012 |
1696 | $datestamp = '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/'; // e. g. 2012-07-13 |
1697 | $timestamp = '/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/'; |
1698 | $timestampMs |
1699 | = '/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z$/'; |
1700 | // e. g. 2017-07-09T18:00:00 |
1701 | |
1702 | if ($date == null || $date == '') { |
1703 | return ''; |
1704 | } elseif (preg_match($compactDate, $date) === 1) { |
1705 | return $this->dateConverter->convertToDisplayDate('Ynd', $date); |
1706 | } elseif (preg_match($euroName, $date) === 1) { |
1707 | return $this->dateConverter->convertToDisplayDate('d/M/Y', $date); |
1708 | } elseif (preg_match($euro, $date) === 1) { |
1709 | return $this->dateConverter->convertToDisplayDate('d/m/Y', $date); |
1710 | } elseif (preg_match($euroPad, $date) === 1) { |
1711 | return $this->dateConverter->convertToDisplayDate('d/m/y', $date); |
1712 | } elseif (preg_match($datestamp, $date) === 1) { |
1713 | return $this->dateConverter->convertToDisplayDate('Y-m-d', $date); |
1714 | } elseif (preg_match($timestamp, $date) === 1) { |
1715 | if ($withTime) { |
1716 | return $this->dateConverter->convertToDisplayDateAndTime( |
1717 | 'Y-m-d\TH:i:sT', |
1718 | $date |
1719 | ); |
1720 | } else { |
1721 | return $this->dateConverter->convertToDisplayDate( |
1722 | 'Y-m-d', |
1723 | substr($date, 0, 10) |
1724 | ); |
1725 | } |
1726 | } elseif (preg_match($timestampMs, $date) === 1) { |
1727 | if ($withTime) { |
1728 | return $this->dateConverter->convertToDisplayDateAndTime( |
1729 | 'Y-m-d\TH:i:s#???T', |
1730 | $date |
1731 | ); |
1732 | } else { |
1733 | return $this->dateConverter->convertToDisplayDate( |
1734 | 'Y-m-d', |
1735 | substr($date, 0, 10) |
1736 | ); |
1737 | } |
1738 | } else { |
1739 | throw new \Exception("Invalid date: $date"); |
1740 | } |
1741 | } |
1742 | |
1743 | /** |
1744 | * Helper method to determine whether or not a certain method can be |
1745 | * called on this driver. Required method for any smart drivers. |
1746 | * |
1747 | * @param string $method The name of the called method. |
1748 | * @param array $params Array of passed parameters |
1749 | * |
1750 | * @return bool True if the method can be called with the given parameters, |
1751 | * false otherwise. |
1752 | * |
1753 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) |
1754 | */ |
1755 | public function supportsMethod($method, $params) |
1756 | { |
1757 | return is_callable([$this, $method]); |
1758 | } |
1759 | |
1760 | /** |
1761 | * Get the inventory types to be displayed. Possible values are: |
1762 | * p_avail,e_avail,d_avail |
1763 | * |
1764 | * @return array |
1765 | */ |
1766 | protected function getInventoryTypes() |
1767 | { |
1768 | $types = explode( |
1769 | ':', |
1770 | $this->config['Holdings']['inventoryTypes'] |
1771 | ?? 'physical:digital:electronic' |
1772 | ); |
1773 | |
1774 | $result = []; |
1775 | $map = [ |
1776 | 'physical' => 'p_avail', |
1777 | 'digital' => 'd_avail', |
1778 | 'electronic' => 'e_avail', |
1779 | ]; |
1780 | $types = array_flip($types); |
1781 | foreach ($map as $src => $dest) { |
1782 | if (isset($types[$src])) { |
1783 | $result[] = $dest; |
1784 | } |
1785 | } |
1786 | |
1787 | return $result; |
1788 | } |
1789 | |
1790 | /** |
1791 | * Get Statuses for inventory types |
1792 | * |
1793 | * This is responsible for retrieving the status information for a |
1794 | * collection of records with specified inventory types. |
1795 | * |
1796 | * @param array $ids The array of record ids to retrieve the status for |
1797 | * @param array $types Inventory types |
1798 | * |
1799 | * @return array An array of getStatus() return values on success. |
1800 | */ |
1801 | protected function getStatusesForInventoryTypes($ids, $types) |
1802 | { |
1803 | $results = []; |
1804 | $params = [ |
1805 | 'mms_id' => implode(',', $ids), |
1806 | 'expand' => implode(',', $types), |
1807 | ]; |
1808 | if ($bibs = $this->makeRequest('/bibs', $params)) { |
1809 | foreach ($bibs as $bib) { |
1810 | $marc = new MarcReader($bib->record->asXML()); |
1811 | $status = []; |
1812 | $tmpl = [ |
1813 | 'id' => (string)$bib->mms_id, |
1814 | 'source' => 'Solr', |
1815 | 'callnumber' => '', |
1816 | 'reserve' => 'N', |
1817 | ]; |
1818 | // Physical |
1819 | $physicalItems = $marc->getFields('AVA'); |
1820 | foreach ($physicalItems as $field) { |
1821 | $available = null; |
1822 | $statusText = ''; |
1823 | if ($this->locationTypeToItemStatus) { |
1824 | $locationCode = $marc->getSubfield($field, 'j'); |
1825 | $library = $marc->getSubfield($field, 'b'); |
1826 | [$available, $statusText] = $this->getItemStatusFromLocationTypeMap( |
1827 | $this->getLocationType($library, $locationCode) |
1828 | ); |
1829 | } |
1830 | |
1831 | if (null === $available) { |
1832 | $availStr = strtolower($marc->getSubfield($field, 'e')); |
1833 | $available = 'available' === $availStr; |
1834 | // No status message available, so set it based on availability: |
1835 | $statusText = $available ? 'Item in place' : 'Item not in place'; |
1836 | } |
1837 | |
1838 | $item = $tmpl; |
1839 | $item['availability'] = $available; |
1840 | $item['status'] = $statusText; |
1841 | $item['location'] = $marc->getSubfield($field, 'c'); |
1842 | $item['callnumber'] = $marc->getSubfield($field, 'd'); |
1843 | $status[] = $item; |
1844 | } |
1845 | // Electronic |
1846 | $electronicItems = $marc->getFields('AVE'); |
1847 | foreach ($electronicItems as $field) { |
1848 | $avail = $marc->getSubfield($field, 'e'); |
1849 | $item = $tmpl; |
1850 | $item['availability'] = strtolower($avail) === 'available'; |
1851 | // Use the following subfields for location: |
1852 | // m (Collection name) |
1853 | // i (Available for library) |
1854 | // d (Available for library) |
1855 | // b (Available for library) |
1856 | $location = [$marc->getSubfield($field, 'm') ?: 'Get full text']; |
1857 | foreach (['i', 'd', 'b'] as $code) { |
1858 | if ($content = $marc->getSubfield($field, $code)) { |
1859 | $location[] = $content; |
1860 | } |
1861 | } |
1862 | $item['location'] = implode(' - ', $location); |
1863 | $item['callnumber'] = $marc->getSubfield($field, 't'); |
1864 | $url = $marc->getSubfield($field, 'u'); |
1865 | if (preg_match('/^https?:\/\//', $url)) { |
1866 | $item['locationhref'] = $url; |
1867 | } |
1868 | $item['status'] = $marc->getSubfield($field, 's') ?: null; |
1869 | if ($note = $marc->getSubfield($field, 'n')) { |
1870 | $item['item_notes'] = [$note]; |
1871 | } |
1872 | $status[] = $item; |
1873 | } |
1874 | // Digital |
1875 | $deliveryUrl |
1876 | = $this->config['Holdings']['digitalDeliveryUrl'] ?? ''; |
1877 | $digitalItems = $marc->getFields('AVD'); |
1878 | if ($digitalItems && !$deliveryUrl) { |
1879 | $this->logWarning( |
1880 | 'Digital items exist for ' . (string)$bib->mms_id |
1881 | . ', but digitalDeliveryUrl not set -- unable to' |
1882 | . ' generate links' |
1883 | ); |
1884 | } |
1885 | foreach ($digitalItems as $field) { |
1886 | $item = $tmpl; |
1887 | unset($item['callnumber']); |
1888 | $item['availability'] = true; |
1889 | $item['location'] = $marc->getSubfield($field, 'e'); |
1890 | // Using subfield 'd' ('Repository Name') as callnumber |
1891 | $item['callnumber'] = $marc->getSubfield($field, 'd'); |
1892 | if ($deliveryUrl) { |
1893 | $item['locationhref'] = str_replace( |
1894 | '%%id%%', |
1895 | $marc->getSubfield($field, 'b'), |
1896 | $deliveryUrl |
1897 | ); |
1898 | } |
1899 | $status[] = $item; |
1900 | } |
1901 | $results[(string)$bib->mms_id] = $status; |
1902 | } |
1903 | } |
1904 | return $results; |
1905 | } |
1906 | |
1907 | /** |
1908 | * Get the preferred email address for the user (or first one if no preferred one |
1909 | * is found) |
1910 | * |
1911 | * @param SimpleXMLElement $user User data |
1912 | * |
1913 | * @return string|null |
1914 | */ |
1915 | protected function getPreferredEmail($user) |
1916 | { |
1917 | if (!empty($user->contact_info->emails->email)) { |
1918 | foreach ($user->contact_info->emails->email as $email) { |
1919 | if ('true' === (string)$email['preferred']) { |
1920 | return isset($email->email_address) |
1921 | ? trim((string)$email->email_address) : null; |
1922 | } |
1923 | } |
1924 | $email = $user->contact_info->emails->email[0]; |
1925 | return isset($email->email_address) |
1926 | ? (string)$email->email_address : null; |
1927 | } |
1928 | return null; |
1929 | } |
1930 | |
1931 | /** |
1932 | * Gets a translatable string from an element with content and a desc attribute. |
1933 | * |
1934 | * @param SimpleXMLElement $element XML element |
1935 | * |
1936 | * @return \VuFind\I18n\TranslatableString |
1937 | */ |
1938 | protected function getTranslatableString($element) |
1939 | { |
1940 | if (null === $element) { |
1941 | return null; |
1942 | } |
1943 | $value = ($this->config['Catalog']['translationPrefix'] ?? '') |
1944 | . (string)$element; |
1945 | $desc = (string)($element->attributes()->desc ?? $element); |
1946 | return new TranslatableString($value, $desc); |
1947 | } |
1948 | |
1949 | /** |
1950 | * Gets a translatable string from an element with content and a desc attribute. |
1951 | * |
1952 | * @param SimpleXMLElement $element XML element |
1953 | * |
1954 | * @return TranslatableString |
1955 | */ |
1956 | protected function getTranslatableStatusString($element) |
1957 | { |
1958 | if (null === $element) { |
1959 | return null; |
1960 | } |
1961 | $value = 'status_' . strtolower((string)$element); |
1962 | $desc = (string)($element->attributes()->desc ?? $element); |
1963 | return new TranslatableString($value, $desc); |
1964 | } |
1965 | |
1966 | /** |
1967 | * Get location for an item |
1968 | * |
1969 | * @param SimpleXMLElement $item Item |
1970 | * |
1971 | * @return TranslatableString|string |
1972 | */ |
1973 | protected function getItemLocation($item) |
1974 | { |
1975 | return $this->getTranslatableString($item->item_data->location); |
1976 | } |
1977 | |
1978 | /** |
1979 | * Get location type for an item |
1980 | * |
1981 | * @param SimpleXMLElement $item Item |
1982 | * |
1983 | * @return string |
1984 | */ |
1985 | protected function getItemLocationType($item) |
1986 | { |
1987 | // Yes, temporary location is in holding data while permanent location is in |
1988 | // item data. |
1989 | if ('true' === (string)$item->holding_data->in_temp_location) { |
1990 | $library = $item->holding_data->temp_library |
1991 | ?: $item->item_data->library; |
1992 | $location = $item->holding_data->temp_location |
1993 | ?: $item->item_data->location; |
1994 | } else { |
1995 | $library = $item->item_data->library; |
1996 | $location = $item->item_data->location; |
1997 | } |
1998 | return $this->getLocationType((string)$library, (string)$location); |
1999 | } |
2000 | |
2001 | /** |
2002 | * Get type of a location |
2003 | * |
2004 | * @param string $library Library |
2005 | * @param string $location Location |
2006 | * |
2007 | * @return string |
2008 | */ |
2009 | protected function getLocationType($library, $location) |
2010 | { |
2011 | $locations = $this->getLocations($library); |
2012 | return $locations[$location]['type'] ?? ''; |
2013 | } |
2014 | |
2015 | /** |
2016 | * Get the locations for a library |
2017 | * |
2018 | * @param string $library Library |
2019 | * |
2020 | * @return array |
2021 | */ |
2022 | protected function getLocations($library) |
2023 | { |
2024 | $cacheId = 'alma|locations|' . $library; |
2025 | $locations = $this->getCachedData($cacheId); |
2026 | |
2027 | if (null === $locations) { |
2028 | $xml = $this->makeRequest( |
2029 | '/conf/libraries/' . rawurlencode($library) . '/locations' |
2030 | ); |
2031 | $locations = []; |
2032 | foreach ($xml as $entry) { |
2033 | $locations[(string)$entry->code] = [ |
2034 | 'name' => (string)$entry->name, |
2035 | 'externalName' => (string)$entry->external_name, |
2036 | 'type' => (string)$entry->type, |
2037 | ]; |
2038 | } |
2039 | $this->putCachedData($cacheId, $locations, 3600); |
2040 | } |
2041 | return $locations; |
2042 | } |
2043 | |
2044 | /** |
2045 | * Get list of funds |
2046 | * |
2047 | * @return array with key = course ID, value = course name |
2048 | */ |
2049 | public function getFunds() |
2050 | { |
2051 | // TODO: implement me! |
2052 | // https://developers.exlibrisgroup.com/alma/apis/acq |
2053 | // GET /almaws/v1/acq/funds |
2054 | return []; |
2055 | } |
2056 | } |