From 6db2151e93f3db10475660398ab3dcf1a75dcd99 Mon Sep 17 00:00:00 2001
From: Ere Maijala <ere.maijala@helsinki.fi>
Date: Mon, 18 Mar 2019 16:51:22 +0200
Subject: [PATCH] Sierra driver holdings improvements (#1338)

- Add support for fetching summary holdings from Sierra when using the REST API.
- Includes a fix for ordering of results.
- Introduces a new "pseudo-version" of the API (5.1) which is currently disabled by default (not supported in all installations at this time). This version must be activated to allow summary holdings support.
---
 config/vufind/SierraRest.ini                  |  26 +-
 .../src/VuFind/ILS/Driver/SierraRest.php      | 277 ++++++++++++++++--
 2 files changed, 280 insertions(+), 23 deletions(-)

diff --git a/config/vufind/SierraRest.ini b/config/vufind/SierraRest.ini
index 207e2a11935..ead3b1f5080 100644
--- a/config/vufind/SierraRest.ini
+++ b/config/vufind/SierraRest.ini
@@ -12,9 +12,12 @@ host = "https://sandbox.iii.com/iii/sierra-api"
 client_key = "something"
 ; Sierra API client secret
 client_secret = "very_secret"
-; Sierra API version available (defaults to highest one required for full
-; functionality in the driver)
-;api_version = 5
+; Sierra API version available. Functionality requiring a specific minimum version:
+;   5 (default):
+;     - last pickup date for holds
+;   5.1 (technically still v5 but added in a later revision):
+;     - summary holdings information (especially for serials)
+;api_version = 5.1
 ; Timeout for HTTP requests
 http_timeout = 30
 ; Redirect URL entered in Sierra for the patron-specific authentication (does not
@@ -120,3 +123,20 @@ title_hold_bib_levels = a:b:m:d
 [TransactionHistory]
 ; By default the loan history is disabled. Uncomment the following line to enable it.
 ;enabled = true
+
+; Both MARC field+subfields and Sierra field tags may be used to specify which fields
+; are included as textual information from holdings records. Note that Sierra does
+; not use MARC tags for all holdings information, and e.g. 'h' may cover several
+; fields of which only some are mapped to MARC fields.
+[Holdings]
+; Holdings fields to include in notes. Default is none.
+;notes = "n"
+; Holdings fields to include in summary. Default is "h".
+;summary = "863abiz:866az:h"
+; Holdings fields to include in supplements. Default is none.
+;supplements = "867az"
+; Holdings fields to include in indexes. Default is none.
+;indexes = "868az"
+; Whether to sort items by enum/chron (v field) instead of order they are returned
+; from Sierra. Default is true.
+;sort_by_enum_chron = false
diff --git a/module/VuFind/src/VuFind/ILS/Driver/SierraRest.php b/module/VuFind/src/VuFind/ILS/Driver/SierraRest.php
index 1e63f58a606..94d7e8ab2ab 100644
--- a/module/VuFind/src/VuFind/ILS/Driver/SierraRest.php
+++ b/module/VuFind/src/VuFind/ILS/Driver/SierraRest.php
@@ -4,7 +4,7 @@
  *
  * PHP version 7
  *
- * Copyright (C) The National Library of Finland 2016-2018.
+ * Copyright (C) The National Library of Finland 2016-2019.
  *
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License version 2,
@@ -118,7 +118,7 @@ class SierraRest extends AbstractBase implements TranslatorAwareInterface,
     /**
      * Item statuses that allow placing a hold
      *
-     * @var unknown
+     * @var array
      */
     protected $validHoldStatuses;
 
@@ -146,10 +146,24 @@ class SierraRest extends AbstractBase implements TranslatorAwareInterface,
     /**
      * Available API version
      *
+     * Functionality requiring a specific minimum version:
+     *
+     * v5:
+     *   - last pickup date for holds
+     * v5.1 (technically still v5 but added in a later revision):
+     *   - summary holdings information (especially for serials)
+     *
      * @var int
      */
     protected $apiVersion = 5;
 
+    /**
+     * Whether to sort items by enumchron. Default is true.
+     *
+     * @var array
+     */
+    protected $sortItemsByEnumChron;
+
     /**
      * Constructor
      *
@@ -235,6 +249,9 @@ class SierraRest extends AbstractBase implements TranslatorAwareInterface,
             $this->apiVersion = $this->config['Catalog']['api_version'];
         }
 
+        $this->sortItemsByEnumChron
+            = $this->config['Holdings']['sort_by_enum_chron'] ?? true;
+
         // Init session cache for session-specific data
         $namespace = md5(
             $this->config['Catalog']['host'] . '|'
@@ -1617,6 +1634,35 @@ class SierraRest extends AbstractBase implements TranslatorAwareInterface,
     protected function getItemStatusesForBib($id)
     {
         $bib = $this->getBibRecord($id, 'bibLevel');
+        $holdingsData = [];
+        if ($this->apiVersion >= 5.1) {
+            $holdingsResult = $this->makeRequest(
+                ['v5', 'holdings'],
+                [
+                    'bibIds' => $this->extractBibId($id),
+                    //'deleted' => 'false',
+                    //'suppressed' => 'false',
+                    'fields' => 'fixedFields,varFields'
+                ],
+                'GET'
+            );
+            if (!empty($holdingsResult['entries'])) {
+                foreach ($holdingsResult['entries'] as $entry) {
+                    $location = '';
+                    foreach ($entry['fixedFields'] as $field) {
+                        if ('LOCATION' === $field['label']) {
+                            $location = $field['value'];
+                            break;
+                        }
+                    }
+                    if ('' === $location) {
+                        continue;
+                    }
+                    $holdingsData[$location][] = $entry;
+                }
+            }
+        }
+
         $offset = 0;
         $limit = 50;
         $fields = 'location,status,barcode,callNumber,fixedFields';
@@ -1625,6 +1671,7 @@ class SierraRest extends AbstractBase implements TranslatorAwareInterface,
             $fields .= ',varFields';
         }
         $statuses = [];
+        $sort = 0;
         while (!isset($result) || $limit === $result['total']) {
             $result = $this->makeRequest(
                 ['v3', 'items'],
@@ -1678,7 +1725,7 @@ class SierraRest extends AbstractBase implements TranslatorAwareInterface,
                     'duedate' => $duedate,
                     'number' => $volume,
                     'barcode' => $item['barcode'],
-                    'sort' => $i
+                    'sort' => $sort--
                 ];
                 if ($notes) {
                     $entry['item_notes'] = $notes;
@@ -1693,15 +1740,202 @@ class SierraRest extends AbstractBase implements TranslatorAwareInterface,
                     $entry['is_holdable'] = false;
                 }
 
+                $locationCode = $item['location']['code'] ?? '';
+                if (!empty($holdingsData[$locationCode])) {
+                    $entry += $this->getHoldingsData($holdingsData[$locationCode]);
+                    $holdingsData[$locationCode]['_hasItems'] = true;
+                }
+
                 $statuses[] = $entry;
             }
             $offset += $limit;
         }
 
+        // Add holdings that don't have items
+        foreach ($holdingsData as $locationCode => $holdings) {
+            if (!empty($holdings['_hasItems'])) {
+                continue;
+            }
+
+            $location = $this->translateLocation(
+                ['code' => $locationCode, 'name' => '']
+            );
+            $code = $locationCode;
+            while ('' === $location && $code) {
+                $location = $this->getLocationName($code);
+                $code = substr($code, 0, -1);
+            }
+            $entry = [
+                'id' => $id,
+                'item_id' => 'HLD_' . $holdings[0]['id'],
+                'location' => $location,
+                'requests_placed' => 0,
+                'status' => '',
+                'use_unknown_message' => true,
+                'availability' => false,
+                'duedate' => '',
+                'barcode' => '',
+                'sort' => $sort--
+            ];
+            $entry += $this->getHoldingsData($holdings);
+
+            $statuses[] = $entry;
+        }
+
         usort($statuses, [$this, 'statusSortFunction']);
         return $statuses;
     }
 
+    /**
+     * Get holdings fields according to configuration
+     *
+     * @param array $holdings Holdings records
+     *
+     * @return array
+     */
+    protected function getHoldingsData($holdings)
+    {
+        $result = [];
+        // Get Notes
+        if (isset($this->config['Holdings']['notes'])) {
+            $data = $this->getHoldingFields(
+                $holdings,
+                $this->config['Holdings']['notes']
+            );
+            if ($data) {
+                $result['notes'] = $data;
+            }
+        }
+
+        // Get Summary (may be multiple lines)
+        $data = $this->getHoldingFields(
+            $holdings,
+            isset($this->config['Holdings']['summary'])
+            ? $this->config['Holdings']['summary']
+            : 'h'
+        );
+        if ($data) {
+            $result['summary'] = $data;
+        }
+
+        // Get Supplements
+        if (isset($this->config['Holdings']['supplements'])) {
+            $data = $this->getHoldingFields(
+                $holdings,
+                $this->config['Holdings']['supplements']
+            );
+            if ($data) {
+                $result['supplements'] = $data;
+            }
+        }
+
+        // Get Indexes
+        if (isset($this->config['Holdings']['indexes'])) {
+            $data = $this->getHoldingFields(
+                $holdings,
+                $this->config['Holdings']['indexes']
+            );
+            if ($data) {
+                $result['indexes'] = $data;
+            }
+        }
+        return $result;
+    }
+
+    /**
+     * Get fields from holdings according to the field spec.
+     *
+     * @param array        $holdings   Holdings records
+     * @param array|string $fieldSpecs Array or colon-separated list of
+     * field/subfield specifications (3 chars for field code and then subfields,
+     * e.g. 866az)
+     *
+     * @return string|string[] Results as a string if single, array if multiple
+     */
+    protected function getHoldingFields($holdings, $fieldSpecs)
+    {
+        if (!is_array($fieldSpecs)) {
+            $fieldSpecs = explode(':', $fieldSpecs);
+        }
+        $result = [];
+        foreach ($holdings as $holding) {
+            foreach ($fieldSpecs as $fieldSpec) {
+                $fieldCode = substr($fieldSpec, 0, 3);
+                $subfieldCodes = substr($fieldSpec, 3);
+                $fields = $holding['varFields'] ?? [];
+                foreach ($fields as $field) {
+                    if (($field['marcTag'] ?? '') !== $fieldCode
+                        && ($field['fieldTag'] ?? '') !== $fieldCode
+                    ) {
+                        continue;
+                    }
+                    $subfields = $field['subfields'] ?? [
+                        [
+                            'tag' => '',
+                            'content' => $field['content'] ?? ''
+                        ]
+                    ];
+                    $line = [];
+                    foreach ($subfields as $subfield) {
+                        if ($subfieldCodes
+                            && false === strpos($subfieldCodes, $subfield['tag'])
+                        ) {
+                            continue;
+                        }
+                        $line[] = $subfield['content'];
+                    }
+                    if ($line) {
+                        $result[] = implode(' ', $line);
+                    }
+                }
+            }
+        }
+        if (!$result) {
+            return '';
+        }
+        return isset($result[1]) ? $result : $result[0];
+    }
+
+    /**
+     * Get name for a location code
+     *
+     * @param string $locationCode Location code
+     *
+     * @return string
+     */
+    protected function getLocationName($locationCode)
+    {
+        $locations = $this->getCachedData('locations');
+        if (null === $locations) {
+            $locations = [];
+            $result = $this->makeRequest(
+                ['v4', 'branches'],
+                [
+                    'limit' => 10000,
+                    'fields' => 'locations'
+                ],
+                'GET'
+            );
+            if (!empty($result['code'])) {
+                // An error was returned
+                $this->error(
+                    "Request for branches returned error code: {$result['code']}, "
+                    . "HTTP status: {$result['httpStatus']}, name: {$result['name']}"
+                );
+                throw new ILSException('Problem with Sierra REST API.');
+            }
+            foreach (($result['entries'] ?? []) as $branch) {
+                foreach (($branch['locations'] ?? []) as $location) {
+                    $locations[$location['code']] = $this->translateLocation(
+                        $location
+                    );
+                }
+            }
+            $this->putCachedData('locations', $locations);
+        }
+        return $locations[$locationCode] ?? '';
+    }
+
     /**
      * Translate location name
      *
@@ -1722,6 +1956,26 @@ class SierraRest extends AbstractBase implements TranslatorAwareInterface,
         );
     }
 
+    /**
+     * Status item sort function
+     *
+     * @param array $a First status record to compare
+     * @param array $b Second status record to compare
+     *
+     * @return int
+     */
+    protected function statusSortFunction($a, $b)
+    {
+        $result = strcmp($a['location'], $b['location']);
+        if ($result === 0 && $this->sortItemsByEnumChron) {
+            $result = strnatcmp($b['number'] ?? '', $a['number'] ?? '');
+        }
+        if ($result === 0) {
+            $result = $a['sort'] - $b['sort'];
+        }
+        return $result;
+    }
+
     /**
      * Translate OPAC message
      *
@@ -1880,23 +2134,6 @@ class SierraRest extends AbstractBase implements TranslatorAwareInterface,
         return empty($blockReason) ? false : $blockReason;
     }
 
-    /**
-     * Status item sort function
-     *
-     * @param array $a First status record to compare
-     * @param array $b Second status record to compare
-     *
-     * @return int
-     */
-    protected function statusSortFunction($a, $b)
-    {
-        $result = strcmp($a['location'], $b['location']);
-        if ($result == 0) {
-            $result = $a['sort'] - $b['sort'];
-        }
-        return $result;
-    }
-
     /**
      * Pickup location sort function
      *
-- 
GitLab