Custom best-hold selection sort order
authorLebbeous Fogle-Weekley <lebbeous@esilibrary.com>
Thu, 13 Dec 2012 19:45:41 +0000 (14:45 -0500)
committerMike Rylander <mrylander@gmail.com>
Wed, 27 Feb 2013 15:58:06 +0000 (10:58 -0500)
The ranking algorithm that chooses the best hold to target a copy in
hand at a capture time used to be fairly simple.  It had two modes, FIFO
and not-FIFO, and that was it.

This change allows full configuration of that algorithm.  In other
words, when the system captures a copy and sets out to evaluate what
hold, if any, that copy might best fulfull, site staff of sufficient
permission level are now empowered to choose exactly which comparisons
the systems makes in what order.  This gives said staff much greater
flexibililty than they have today over holds policy.

For more information, see the included tech spec documents.

Signed-off-by: Lebbeous Fogle-Weekley <lebbeous@esilibrary.com>

Conflicts:
Open-ILS/src/sql/Pg/002.schema.config.sql
Open-ILS/src/sql/Pg/950.data.seed-values.sql

Signed-off-by: Mike Rylander <mrylander@gmail.com>

19 files changed:
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/action.pm
Open-ILS/src/sql/Pg/002.schema.config.sql
Open-ILS/src/sql/Pg/005.schema.actors.sql
Open-ILS/src/sql/Pg/090.schema.action.sql
Open-ILS/src/sql/Pg/800.fkeys.sql
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/upgrade/XXXX.schema.org_prox_adjust.sql
Open-ILS/src/sql/Pg/upgrade/XXYY.schema.custom-best-hold-selection.sql [new file with mode: 0644]
Open-ILS/src/templates/conify/global/config/best_hold_order.tt2 [new file with mode: 0644]
Open-ILS/web/js/dojo/openils/conify/BestHoldOrder.js [new file with mode: 0644]
Open-ILS/web/js/dojo/openils/conify/nls/conify.js
Open-ILS/web/js/dojo/openils/widget/AutoFieldWidget.js
Open-ILS/web/opac/locale/en-US/lang.dtd
Open-ILS/xul/staff_client/chrome/content/main/menu.js
Open-ILS/xul/staff_client/chrome/content/main/menu_frame_menus.xul
docs/RELEASE_NOTES_NEXT/custom-best-hold-selection.txt [new file with mode: 0644]
docs/TechRef/Circ/custom-best-hold-selection.txt [new file with mode: 0644]

index 771ab0b..f656573 100644 (file)
@@ -2358,6 +2358,32 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
             </actions>
         </permacrud>
        </class>
+       <class id="cbho" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="config::best_hold_order" oils_persist:tablename="config.best_hold_order" reporter:label="Best-Hold Sort Order">
+               <fields oils_persist:primary="id" oils_persist:sequence="config.best_hold_order_id_seq">
+                       <field reporter:label="ID" name="id" reporter:datatype="id" reporter:selector="name" />
+                       <field reporter:label="Name" name="name" reporter:datatype="text"/>
+                       <field reporter:label="Capture Lib to Pickup Lib Proximity" name="pprox" reporter:datatype="int" />
+                       <field reporter:label="Circ Lib to Request Lib Proximity" name="hprox" reporter:datatype="int" />
+                       <field reporter:label="Adjusted Circ Lib to Pickup Lib Proximity" name="aprox" reporter:datatype="int" />
+                       <field reporter:label="Adjusted Capture Location to Pickup Lib Proximity" name="approx" reporter:datatype="int" />
+                       <field reporter:label="Hold Priority" name="priority" reporter:datatype="int" />
+                       <field reporter:label="Hold Cut-in-line State" name="cut" reporter:datatype="int" />
+                       <field reporter:label="Hold Selection Depth" name="depth" reporter:datatype="int" />
+                       <field reporter:label="Copy Has Circulated From Home Lately" name="htime" reporter:datatype="int" />
+                       <field reporter:label="Hold Request Time" name="rtime" reporter:datatype="int" />
+                       <field reporter:label="Copy Has Been Home At All Lately" name="shtime" reporter:datatype="int" />
+               </fields>
+               <links>
+               </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <create permission="ADMIN_HOLD_CAPTURE_SORT" global_required="true"/>
+                               <retrieve permission="ADMIN_HOLD_CAPTURE_SORT" global_required="true"/>
+                               <update permission="ADMIN_HOLD_CAPTURE_SORT" global_required="true"/>
+                               <delete permission="ADMIN_HOLD_CAPTURE_SORT" global_required="true"/>
+                       </actions>
+               </permacrud>
+       </class>
        <class id="cbfp" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="config::biblio_fingerprint" oils_persist:tablename="config.biblio_fingerprint" reporter:label="Fingerprint Definition">
                <fields oils_persist:primary="id" oils_persist:sequence="config.biblio_fingerprint_id_seq">
                        <field name="id" reporter:datatype="id" />
index 40cf63e..03f6c98 100644 (file)
@@ -3000,10 +3000,10 @@ sub find_nearest_permitted_hold {
 
     my $fifo = $U->ou_ancestor_setting_value($user->ws_ou, 'circ.holds_fifo');
 
-    # search for what should be the best holds for this copy to fulfill
-    my $best_holds = $U->storagereq(
-        "open-ils.storage.action.hold_request.nearest_hold.atomic",
-        $user->ws_ou, $copy->id, 100, $hold_stall_interval, $fifo );
+       # search for what should be the best holds for this copy to fulfill
+       my $best_holds = $U->storagereq(
+        "open-ils.storage.action.hold_request.nearest_hold.atomic", 
+               $user->ws_ou, $copy, 100, $hold_stall_interval, $fifo );
 
     # Add any pre-targeted holds to the list too? Unless they are already there, anyway.
     if ($old_holds) {
index 60fe6b7..05dedcf 100644 (file)
@@ -17,6 +17,32 @@ use OpenILS::Application::Circ::CircCommon;
 use OpenILS::Application::AppUtils;
 my $U = "OpenILS::Application::AppUtils";
 
+# used in build_hold_sort_clause()
+my %HOLD_SORT_ORDER_BY = (
+    pprox => 'p.prox',
+    hprox => 'actor.org_unit_proximity(%d, h.request_lib)',  # $cp->circ_lib
+    aprox => 'COALESCE(hm.proximity, p.prox)',
+    approx => 'action.hold_copy_calculated_proximity(h.id, %d, %d)', # $cp,$here
+    priority => 'pgt.hold_priority',
+    cut => 'CASE WHEN h.cut_in_line IS TRUE THEN 0 ELSE 1 END',
+    depth => 'h.selection_depth',
+    rtime => 'h.request_time',
+    htime => q!
+        CASE WHEN
+            copy_has_not_been_home.result
+        THEN actor.org_unit_proximity(%d, h.request_lib)
+        ELSE 999
+        END
+    !,
+    shtime => q!
+        CASE WHEN
+            copy_has_not_been_home_even_to_idle.result
+        THEN actor.org_unit_proximity(%d, h.request_lib)
+        ELSE 999
+        END
+    !,
+);
+
 
 sub isTrue {
        my $v = shift;
@@ -282,22 +308,183 @@ __PACKAGE__->register_method(
        method          => 'grab_overdue',
 );
 
+sub get_hold_sort_order {
+    my ($ou) = @_;
+
+    my $dbh = action::hold_request->db_Main;
+
+    # The purpose of this function is to return column names in a DB-configured
+    # order, so it won't do to add columns here or change column names unless
+    # you also change the expectation of anything calling this function.
+
+    my $row = $dbh->selectrow_hashref(
+        q!
+        SELECT
+            cbho.pprox, cbho.hprox, cbho.aprox, cbho.approx, cbho.priority,
+            cbho.cut, cbho.depth, cbho.htime, cbho.shtime, cbho.rtime
+        FROM config.best_hold_order cbho
+        WHERE id = (
+            SELECT oils_json_to_text(value)::INT
+            FROM actor.org_unit_ancestor_setting('circ.hold_capture_order', ?)
+        )
+        !, undef, $ou
+    ) || {
+        pprox => 1, hprox => 8, aprox => 2, priority => 3,
+        cut => 4, depth => 5, htime => 7, rtime => 6
+    };
+
+    # Return only the keys of our hash, sorted by value,
+    # keys for null values omitted.
+    return [
+        grep { defined $row->{$_} } (
+            sort {$row->{$a} cmp $row->{$b}} keys %$row
+        )
+    ];
+}
+
+# Returns an ORDER BY clause
+# *and* a string with a CTE expression to precede the nearest-hold SQL query
+# *and* a string with extra JOIN statements needed
+sub build_hold_sort_clause {
+    my ($columns, $cp, $here) = @_;
+
+    my %order_by_sprintf_args = (
+        hprox => [$cp->circ_lib],
+        approx => [$cp->id, $here],
+        htime => [$cp->circ_lib],
+        shtime => [$cp->circ_lib]
+    );
+
+    my @clauses;
+    my $ctes_needed = 0;
+    foreach my $col (@$columns) {
+        if ($col eq 'htime' and not $ctes_needed) {
+            $ctes_needed = 1;
+        } elsif ($col eq 'shtime') {
+            $ctes_needed = 2;
+        }
+
+        my @args;
+        @args = @{$order_by_sprintf_args{$col}} if
+            exists $order_by_sprintf_args{$col};
+
+        push @clauses, sprintf($HOLD_SORT_ORDER_BY{$col}, @args);
+
+        last if $col eq 'rtime';    # rtime is effectively unique, no need for
+                                    # more order-by clauses after that.
+    }
+
+    my ($ctes, $joins);
+    if ($ctes_needed >= 1) {
+        # For our first auxiliary query, the question we seek to answer is, "has
+        # our copy been circulating away from home too long?" Two parts to
+        # answer this question.
+        #
+        # part 1: Have their been no checkouts at the copy's circ_lib since the
+        # beginning of our go-home interval?
+        # part 2: Was the last transit to affect our copy before the beginning
+        # of our go-home interval an outbound transit? i.e. away from circ-lib
+
+        # [We use sprintf because the outer function that's going to send one
+        # big query through DBI is blind to our process of dynamically building
+        # these CTEs, and it wouldn't know what bind parameters to pass unless
+        # we did a lot more work here. This is injection-safe because we only
+        # use the %d formatter.]
+        $ctes .= sprintf(q!
+, copy_has_not_been_home AS (
+    SELECT (
+        -- part 1
+        SELECT circ.id FROM action.circulation circ
+        JOIN go_home_interval ON (true)
+        WHERE
+            circ.target_copy = %d AND
+            circ.circ_lib = %d AND
+            circ.xact_start >= NOW() - go_home_interval.value
+    ) IS NULL AND (
+        -- part 2
+        SELECT atc.dest <> %d FROM action.transit_copy atc
+        JOIN go_home_interval ON (true)
+        WHERE
+            atc.id = (
+                SELECT MAX(id) FROM action.transit_copy atc_inner
+                WHERE
+                    atc_inner.target_copy = %d AND
+                    atc_inner.source_send_time < NOW() - go_home_interval.value
+            )
+    ) AS result
+) !, $cp->id, $cp->circ_lib, $cp->circ_lib, $cp->id);
+        $joins .= " JOIN copy_has_not_been_home ON (true) ";
+    }
+
+    if ($ctes_needed == 2) {
+        # In this auxiliary query, we ask the question, "has our copy come home
+        # by any means that we can determine, even if it didn't circulate once
+        # it came home, in the time defined by the go-home-interval?"
+        # answer this question. Two parts to this too (besides including the
+        # previous auxiliary query).
+        #
+        # 1: there have been no homebound transits for this copy since the
+        # beginning of the go-home interval.
+        # 2: there have been no checkins at home since the beginning of
+        # the go-home interval for this copy
+
+        $ctes .= sprintf(q!
+, copy_has_not_been_home_even_to_idle AS (
+    SELECT
+        copy_has_not_been_home.response AND (
+            -- part 1
+            SELECT atc.id FROM action.transit_copy atc
+            JOIN go_home_interval ON (true)
+            WHERE
+                atc.target_copy = %d AND
+                atc.dest = %d AND
+                atc.dest_recv_time >= NOW() - go_home_interval.value
+        ) IS NULL AND (
+            -- part 2
+            SELECT circ.id FROM action.circulation circ
+            JOIN go_home_interval ON (true)
+            WHERE
+                circ.target_copy = %d AND
+                circ.checkin_lib = %d AND
+                circ.checkin_time >= NOW() - go_home_interval.value
+        ) IS NULL
+    AS result
+) !, $cp->id, $cp->circ_lib, $cp->id, $cp->circ_lib);
+        $joins .= " JOIN copy_has_not_been_home_even_to_idle ON (true) ";
+    }
+
+    return (
+        join(", ", @clauses),
+        $ctes,
+        $joins
+    );
+}
+
 sub nearest_hold {
        my $self = shift;
        my $client = shift;
-       my $here = shift;
-       my $cp = shift;
+       my $here = shift;   # just the ID
+       my $cp = shift;     # now an object, formerly just the ID
        my $limit = int(shift()) || 10;
        my $age = shift() || '0 seconds';
        my $fifo = shift();
 
-       local $OpenILS::Application::Storage::WRITE = 1;
+    $log->info("deprecated 'fifo' param true, but ignored") if isTrue $fifo;
 
-       my $holdsort = isTrue($fifo) ?
-                       "pgt.hold_priority, CASE WHEN h.cut_in_line IS TRUE THEN 0 ELSE 1 END, h.request_time, h.selection_depth DESC, COALESCE(hm.proximity, h.prox) " :
-                       "COALESCE(hm.proximity, h.prox), pgt.hold_priority, CASE WHEN h.cut_in_line IS TRUE THEN 0 ELSE 1 END, h.selection_depth DESC, h.request_time ";
+    my ($holdsort, $addl_cte, $addl_join) =
+        build_hold_sort_clause(get_hold_sort_order($here), $cp, $here);
 
-       my $ids = action::hold_request->db_Main->selectcol_arrayref(<<" SQL", {}, $here, $cp, $age);
+       local $OpenILS::Application::Storage::WRITE = 1;
+
+       my $ids = action::hold_request->db_Main->selectcol_arrayref(<<" SQL", {}, $cp->circ_lib, $here, $cp->id, $age);
+        WITH go_home_interval AS (
+            SELECT OILS_JSON_TO_TEXT(
+                (SELECT value FROM actor.org_unit_ancestor_setting(
+                    'circ.hold_go_home_interval', ?
+                )
+            ))::INTERVAL AS value
+        )
+        $addl_cte
                SELECT  h.id
                  FROM  action.hold_request h
                        JOIN actor.org_unit_proximity p ON (p.from_org = ? AND p.to_org = h.pickup_lib)
@@ -308,6 +495,7 @@ sub nearest_hold {
                                ON ( au.id = ausp.usr AND ( ausp.stop_date IS NULL OR ausp.stop_date > NOW() ) )
                        LEFT JOIN config.standing_penalty csp
                                ON ( csp.id = ausp.standing_penalty AND csp.block_list LIKE '%CAPTURE%' )
+            $addl_join
                  WHERE hm.target_copy = ?
                        AND (AGE(NOW(),h.request_time) >= CAST(? AS INTERVAL) OR p.prox = 0)
                        AND h.capture_time IS NULL
index 10ad215..4f84162 100644 (file)
@@ -979,7 +979,6 @@ CREATE TABLE config.usr_activity_type (
 CREATE UNIQUE INDEX unique_wwh ON config.usr_activity_type 
     (COALESCE(ewho,''), COALESCE (ewhat,''), COALESCE(ehow,''));
 
-
 CREATE TABLE config.filter_dialog_interface (
     key         TEXT                        PRIMARY KEY,
     description TEXT
@@ -996,5 +995,31 @@ CREATE TABLE config.filter_dialog_filter_set (
     CONSTRAINT cfdfs_name_once_per_lib UNIQUE (name, owning_lib)
 );
 
+CREATE TABLE config.best_hold_order(
+    id          SERIAL      PRIMARY KEY,
+    name        TEXT        UNIQUE,   -- i18n
+    pprox       INT, -- copy capture <-> pickup lib prox
+    hprox       INT, -- copy circ lib <-> request lib prox
+    aprox       INT, -- copy circ lib <-> pickup lib ADJUSTED prox on ahcm
+    approx      INT, -- copy capture <-> pickup lib ADJUSTED prox from function
+    priority    INT, -- group hold priority
+    cut         INT, -- cut-in-line
+    depth       INT, -- selection depth
+    htime       INT, -- time since last home-lib circ exceeds org-unit setting
+    rtime       INT, -- request time
+    shtime      INT  -- time since copy last trip home exceeds org-unit setting
+);
+
+-- At least one of these columns must contain a non-null value
+ALTER TABLE config.best_hold_order ADD CHECK ((
+    pprox IS NOT NULL OR
+    hprox IS NOT NULL OR
+    aprox IS NOT NULL OR
+    priority IS NOT NULL OR
+    cut IS NOT NULL OR
+    depth IS NOT NULL OR
+    htime IS NOT NULL OR
+    rtime IS NOT NULL
+));
 
 COMMIT;
index 176f465..e6d8cd6 100644 (file)
@@ -384,7 +384,7 @@ CREATE TABLE actor.org_unit_proximity_adjustment (
     id                  SERIAL   PRIMARY KEY,
     item_circ_lib       INT         REFERENCES actor.org_unit (id),
     item_owning_lib     INT         REFERENCES actor.org_unit (id),
-    copy_location       INT         REFERENCES asset.copy_location (id),
+    copy_location       INT,        -- REFERENCES asset.copy_location (id),
     hold_pickup_lib     INT         REFERENCES actor.org_unit (id),
     hold_request_lib    INT         REFERENCES actor.org_unit (id),
     pos                 INT         NOT NULL DEFAULT 0,
index 3f7bb6f..fd82cf0 100644 (file)
@@ -973,7 +973,16 @@ query-based fieldsets.
 Returns NULL if successful, or an error message if not.
 $$;
 
-CREATE OR REPLACE FUNCTION action.hold_copy_calculated_proximity(ahr_id INT, acp_id BIGINT, context_ou INT DEFAULT NULL) RETURNS NUMERIC AS $f$
+CREATE OR REPLACE FUNCTION action.hold_copy_calculated_proximity(
+    ahr_id INT,
+    acp_id BIGINT,
+    copy_context_ou INT DEFAULT NULL
+    -- TODO maybe? hold_context_ou INT DEFAULT NULL.  This would optionally
+    -- support an "ahprox" measurement: adjust prox between copy circ lib and
+    -- hold request lib, but I'm unsure whether to use this theoretical
+    -- argument only in the baseline calculation or later in the other
+    -- queries in this function.
+) RETURNS NUMERIC AS $f$
 DECLARE
     aoupa           actor.org_unit_proximity_adjustment%ROWTYPE;
     ahr             action.hold_request%ROWTYPE;
@@ -995,17 +1004,17 @@ BEGIN
     SELECT * INTO acn FROM asset.call_number WHERE id = acp.call_number;
     SELECT * INTO acl FROM asset.copy_location WHERE id = acp.location;
 
-    IF context_ou IS NULL THEN
-        context_ou := acp.circ_lib;
+    IF copy_context_ou IS NULL THEN
+        copy_context_ou := acp.circ_lib;
     END IF;
 
     -- First, gather the baseline proximity of "here" to pickup lib
-    SELECT prox INTO baseline_prox FROM actor.org_unit_proximity WHERE from_org = context_ou AND to_org = ahr.pickup_lib;
+    SELECT prox INTO baseline_prox FROM actor.org_unit_proximity WHERE from_org = copy_context_ou AND to_org = ahr.pickup_lib;
 
     -- Find any absolute adjustments, and set the baseline prox to that
     SELECT  adj.* INTO aoupa
       FROM  actor.org_unit_proximity_adjustment adj
-            LEFT JOIN actor.org_unit_ancestors_distance(context_ou) acp_cl ON (acp_cl.id = adj.item_circ_lib)
+            LEFT JOIN actor.org_unit_ancestors_distance(copy_context_ou) acp_cl ON (acp_cl.id = adj.item_circ_lib)
             LEFT JOIN actor.org_unit_ancestors_distance(acn.owning_lib) acn_ol ON (acn_ol.id = adj.item_owning_lib)
             LEFT JOIN actor.org_unit_ancestors_distance(acl.owning_lib) acl_ol ON (acn_ol.id = adj.copy_location)
             LEFT JOIN actor.org_unit_ancestors_distance(ahr.pickup_lib) ahr_pl ON (ahr_pl.id = adj.hold_pickup_lib)
@@ -1030,7 +1039,7 @@ BEGIN
     FOR aoupa IN
         SELECT  adj.* 
           FROM  actor.org_unit_proximity_adjustment adj
-                LEFT JOIN actor.org_unit_ancestors_distance(context_ou) acp_cl ON (acp_cl.id = adj.item_circ_lib)
+                LEFT JOIN actor.org_unit_ancestors_distance(copy_context_ou) acp_cl ON (acp_cl.id = adj.item_circ_lib)
                 LEFT JOIN actor.org_unit_ancestors_distance(acn.owning_lib) acn_ol ON (acn_ol.id = adj.item_owning_lib)
                 LEFT JOIN actor.org_unit_ancestors_distance(acl.owning_lib) acl_ol ON (acn_ol.id = adj.copy_location)
                 LEFT JOIN actor.org_unit_ancestors_distance(ahr.pickup_lib) ahr_pl ON (ahr_pl.id = adj.hold_pickup_lib)
index 36e9438..fb7b07d 100644 (file)
@@ -40,6 +40,7 @@ ALTER TABLE actor.org_unit ADD CONSTRAINT actor_org_unit_holds_address_fkey FORE
 ALTER TABLE actor.org_unit ADD CONSTRAINT actor_org_unit_ill_address_fkey FOREIGN KEY (ill_address) REFERENCES actor.org_address (id) DEFERRABLE INITIALLY DEFERRED;
 
 ALTER TABLE actor.org_unit_proximity_adjustment ADD CONSTRAINT actor_org_unit_proximity_adjustment_circ_mod_fkey FOREIGN KEY (circ_mod) REFERENCES config.circ_modifier (code) DEFERRABLE INITIALLY DEFERRED;
+ALTER TABLE actor.org_unit_proximity_adjustment ADD CONSTRAINT actor_org_unit_proximity_copy_location_fkey FOREIGN KEY (copy_location) REFERENCES asset.copy_location (id) DEFERRABLE INITIALLY DEFERRED;
 
 ALTER TABLE acq.provider ADD CONSTRAINT acq_provider_edi_default_fkey FOREIGN KEY (edi_default) REFERENCES acq.edi_account (id) DEFERRABLE INITIALLY DEFERRED;
 
index 37e0d32..51d608f 100644 (file)
@@ -1582,9 +1582,9 @@ INSERT INTO permission.perm_list ( id, code, description ) VALUES
  ( 544, 'URL_VERIFY_UPDATE_SETTINGS', oils_i18n_gettext( 544, 
     'Allows a user to configure URL verification org unit settings', 'ppl', 'description')),
  ( 545, 'SAVED_FILTER_DIALOG_FILTERS', oils_i18n_gettext( 545,
-    'Allows users to save and load sets of filters for filter dialogs, available in certain staff interfaces', 'ppl', 'description'))
-
-
+    'Allows users to save and load sets of filters for filter dialogs, available in certain staff interfaces', 'ppl', 'description')),
+ ( 546, 'ADMIN_HOLD_CAPTURE_SORT', oils_i18n_gettext( 546,
+        'Allows a user to make changes to best-hold selection sort order', 'ppl', 'description'))
 ;
 
 
@@ -12412,3 +12412,104 @@ INSERT INTO config.metabib_class_ts_map(field_class, ts_config, index_weight, al
     ('subject','simple','A',true),
     ('subject','english_nostop','C',true),
     ('identifier','simple','A',true);
+
+INSERT INTO config.org_unit_setting_type (
+    name, label, description, datatype, fm_class, update_perm, grp
+) VALUES (
+    'circ.hold_capture_order',
+    oils_i18n_gettext(
+        'circ.hold_capture_order',
+        'Best-hold selection sort order',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'circ.hold_capture_order',
+        'Defines the sort order of holds when selecting a hold to fill using a given copy at capture time',
+        'coust',
+        'description'
+    ),
+    'link',
+    'cbho',
+    543,
+    'holds'
+);
+
+INSERT INTO config.org_unit_setting_type (
+    name, label, description, datatype, update_perm, grp
+) VALUES (
+    'circ.hold_go_home_interval',
+    oils_i18n_gettext(
+        'circ.hold_go_home_interval',
+        'Max foreign-circulation time',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'circ.hold_go_home_interval',
+        'Time a copy can spend circulating away from its circ lib before returning there to fill a hold (if one exists there)',
+        'coust',
+        'description'
+    ),
+    'interval',
+    543,
+    'holds'
+);
+
+
+INSERT INTO config.best_hold_order (
+    name,
+    pprox, aprox, priority, cut, depth, rtime, htime, hprox
+) VALUES (
+    'Traditional',
+    1, 2, 3, 4, 5, 6, 7, 8
+);
+
+INSERT INTO config.best_hold_order (
+    name,
+    hprox, pprox, aprox, priority, cut, depth, rtime, htime
+) VALUES (
+    'Traditional with Holds-always-go-home',
+    1, 2, 3, 4, 5, 6, 7, 8
+);
+
+INSERT INTO config.best_hold_order (
+    name,
+    htime, hprox, pprox, aprox, priority, cut, depth, rtime
+) VALUES (
+    'Traditional with Holds-go-home',
+    1, 2, 3, 4, 5, 6, 7, 8
+);
+
+INSERT INTO config.best_hold_order (
+    name,
+    priority, cut, rtime, depth, pprox, hprox, aprox, htime
+) VALUES (
+    'FIFO',
+    1, 2, 3, 4, 5, 6, 7, 8
+);
+
+INSERT INTO config.best_hold_order (
+    name,
+    hprox, priority, cut, rtime, depth, pprox, aprox, htime
+) VALUES (
+    'FIFO with Holds-always-go-home',
+    1, 2, 3, 4, 5, 6, 7, 8
+);
+
+INSERT INTO config.best_hold_order (
+    name,
+    htime, priority, cut, rtime, depth, pprox, aprox, hprox
+) VALUES (
+    'FIFO with Holds-go-home',
+    1, 2, 3, 4, 5, 6, 7, 8
+);
+
+INSERT INTO actor.org_unit_setting (
+    org_unit, name, value
+) VALUES (
+    (SELECT id FROM actor.org_unit WHERE parent_ou IS NULL),
+    'circ.hold_go_home_interval',
+    '"6 months"'
+);
+
index d9eb082..43876d8 100644 (file)
@@ -32,7 +32,16 @@ CREATE OR REPLACE FUNCTION actor.org_unit_ancestors_distance( INT ) RETURNS TABL
     SELECT * FROM org_unit_ancestors_distance;
 $$ LANGUAGE SQL STABLE ROWS 1;
 
-CREATE OR REPLACE FUNCTION action.hold_copy_calculated_proximity(ahr_id INT, acp_id BIGINT, context_ou INT DEFAULT NULL) RETURNS NUMERIC AS $f$
+CREATE OR REPLACE FUNCTION action.hold_copy_calculated_proximity(
+    ahr_id INT,
+    acp_id BIGINT,
+    copy_context_ou INT DEFAULT NULL
+    -- TODO maybe? hold_context_ou INT DEFAULT NULL.  This would optionally
+    -- support an "ahprox" measurement: adjust prox between copy circ lib and
+    -- hold request lib, but I'm unsure whether to use this theoretical
+    -- argument only in the baseline calculation or later in the other
+    -- queries in this function.
+) RETURNS NUMERIC AS $f$
 DECLARE
     aoupa           actor.org_unit_proximity_adjustment%ROWTYPE;
     ahr             action.hold_request%ROWTYPE;
@@ -54,17 +63,17 @@ BEGIN
     SELECT * INTO acn FROM asset.call_number WHERE id = acp.call_number;
     SELECT * INTO acl FROM asset.copy_location WHERE id = acp.location;
 
-    IF context_ou IS NULL THEN
-        context_ou := acp.circ_lib;
+    IF copy_context_ou IS NULL THEN
+        copy_context_ou := acp.circ_lib;
     END IF;
 
     -- First, gather the baseline proximity of "here" to pickup lib
-    SELECT prox INTO baseline_prox FROM actor.org_unit_proximity WHERE from_org = context_ou AND to_org = ahr.pickup_lib;
+    SELECT prox INTO baseline_prox FROM actor.org_unit_proximity WHERE from_org = copy_context_ou AND to_org = ahr.pickup_lib;
 
     -- Find any absolute adjustments, and set the baseline prox to that
     SELECT  adj.* INTO aoupa
       FROM  actor.org_unit_proximity_adjustment adj
-            LEFT JOIN actor.org_unit_ancestors_distance(context_ou) acp_cl ON (acp_cl.id = adj.item_circ_lib)
+            LEFT JOIN actor.org_unit_ancestors_distance(copy_context_ou) acp_cl ON (acp_cl.id = adj.item_circ_lib)
             LEFT JOIN actor.org_unit_ancestors_distance(acn.owning_lib) acn_ol ON (acn_ol.id = adj.item_owning_lib)
             LEFT JOIN actor.org_unit_ancestors_distance(acl.owning_lib) acl_ol ON (acn_ol.id = adj.copy_location)
             LEFT JOIN actor.org_unit_ancestors_distance(ahr.pickup_lib) ahr_pl ON (ahr_pl.id = adj.hold_pickup_lib)
@@ -89,7 +98,7 @@ BEGIN
     FOR aoupa IN
         SELECT  adj.* 
           FROM  actor.org_unit_proximity_adjustment adj
-                LEFT JOIN actor.org_unit_ancestors_distance(context_ou) acp_cl ON (acp_cl.id = adj.item_circ_lib)
+                LEFT JOIN actor.org_unit_ancestors_distance(copy_context_ou) acp_cl ON (acp_cl.id = adj.item_circ_lib)
                 LEFT JOIN actor.org_unit_ancestors_distance(acn.owning_lib) acn_ol ON (acn_ol.id = adj.item_owning_lib)
                 LEFT JOIN actor.org_unit_ancestors_distance(acl.owning_lib) acl_ol ON (acn_ol.id = adj.copy_location)
                 LEFT JOIN actor.org_unit_ancestors_distance(ahr.pickup_lib) ahr_pl ON (ahr_pl.id = adj.hold_pickup_lib)
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXYY.schema.custom-best-hold-selection.sql b/Open-ILS/src/sql/Pg/upgrade/XXYY.schema.custom-best-hold-selection.sql
new file mode 100644 (file)
index 0000000..55e3906
--- /dev/null
@@ -0,0 +1,148 @@
+BEGIN;
+
+CREATE TABLE config.best_hold_order(
+    id          SERIAL      PRIMARY KEY,    -- (metadata)
+    name        TEXT        UNIQUE,   -- i18n (metadata)
+    pprox       INT, -- copy capture <-> pickup lib prox
+    hprox       INT, -- copy circ lib <-> request lib prox
+    aprox       INT, -- copy circ lib <-> pickup lib ADJUSTED prox on ahcm
+    approx      INT, -- copy capture <-> pickup lib ADJUSTED prox from function
+    priority    INT, -- group hold priority
+    cut         INT, -- cut-in-line
+    depth       INT, -- selection depth
+    htime       INT, -- time since last home-lib circ exceeds org-unit setting
+    rtime       INT, -- request time
+    shtime      INT  -- time since copy last trip home exceeds org-unit setting
+);
+
+-- At least one of these columns must contain a non-null value
+ALTER TABLE config.best_hold_order ADD CHECK ((
+    pprox IS NOT NULL OR
+    hprox IS NOT NULL OR
+    aprox IS NOT NULL OR
+    priority IS NOT NULL OR
+    cut IS NOT NULL OR
+    depth IS NOT NULL OR
+    htime IS NOT NULL OR
+    rtime IS NOT NULL
+));
+
+INSERT INTO config.best_hold_order (
+    name,
+    pprox, aprox, priority, cut, depth, rtime, htime, hprox
+) VALUES (
+    'Traditional',
+    1, 2, 3, 4, 5, 6, 7, 8
+);
+
+INSERT INTO config.best_hold_order (
+    name,
+    hprox, pprox, aprox, priority, cut, depth, rtime, htime
+) VALUES (
+    'Traditional with Holds-always-go-home',
+    1, 2, 3, 4, 5, 6, 7, 8
+);
+
+INSERT INTO config.best_hold_order (
+    name,
+    htime, hprox, pprox, aprox, priority, cut, depth, rtime
+) VALUES (
+    'Traditional with Holds-go-home',
+    1, 2, 3, 4, 5, 6, 7, 8
+);
+
+INSERT INTO config.best_hold_order (
+    name,
+    priority, cut, rtime, depth, pprox, hprox, aprox, htime
+) VALUES (
+    'FIFO',
+    1, 2, 3, 4, 5, 6, 7, 8
+);
+
+INSERT INTO config.best_hold_order (
+    name,
+    hprox, priority, cut, rtime, depth, pprox, aprox, htime
+) VALUES (
+    'FIFO with Holds-always-go-home',
+    1, 2, 3, 4, 5, 6, 7, 8
+);
+
+INSERT INTO config.best_hold_order (
+    name,
+    htime, priority, cut, rtime, depth, pprox, aprox, hprox
+) VALUES (
+    'FIFO with Holds-go-home',
+    1, 2, 3, 4, 5, 6, 7, 8
+);
+
+INSERT INTO permission.perm_list (
+    id, code, description
+) VALUES (
+    543,
+    'ADMIN_HOLD_CAPTURE_SORT',
+    oils_i18n_gettext(
+        543,
+        'Allows a user to make changes to best-hold selection sort order',
+        'ppl',
+        'description'
+    )
+);
+
+INSERT INTO config.org_unit_setting_type (
+    name, label, description, datatype, fm_class, update_perm, grp
+) VALUES (
+    'circ.hold_capture_order',
+    oils_i18n_gettext(
+        'circ.hold_capture_order',
+        'Best-hold selection sort order',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'circ.hold_capture_order',
+        'Defines the sort order of holds when selecting a hold to fill using a given copy at capture time',
+        'coust',
+        'description'
+    ),
+    'link',
+    'cbho',
+    543,
+    'holds'
+);
+
+INSERT INTO config.org_unit_setting_type (
+    name, label, description, datatype, update_perm, grp
+) VALUES (
+    'circ.hold_go_home_interval',
+    oils_i18n_gettext(
+        'circ.hold_go_home_interval',
+        'Max foreign-circulation time',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'circ.hold_go_home_interval',
+        'Time a copy can spend circulating away from its circ lib before returning there to fill a hold (if one exists there)',
+        'coust',
+        'description'
+    ),
+    'interval',
+    543,
+    'holds'
+);
+
+INSERT INTO actor.org_unit_setting (
+    org_unit, name, value
+) VALUES (
+    (SELECT id FROM actor.org_unit WHERE parent_ou IS NULL),
+    'circ.hold_go_home_interval',
+    '"6 months"'
+);
+
+UPDATE actor.org_unit_setting SET
+    name = 'circ.hold_capture_order',
+    value = (SELECT id FROM config.best_hold_order WHERE name = 'FIFO')
+WHERE
+    name = 'circ.holds_fifo' AND value ILIKE '%true%';
+
+COMMIT;
diff --git a/Open-ILS/src/templates/conify/global/config/best_hold_order.tt2 b/Open-ILS/src/templates/conify/global/config/best_hold_order.tt2
new file mode 100644 (file)
index 0000000..14d8bd7
--- /dev/null
@@ -0,0 +1,119 @@
+[% WRAPPER base.tt2 %]
+<style type="text/css">
+    h1 { margin-bottom: 0.5ex; }
+    #cbho-loading { text-align: center; }
+    #cbho-edit-space { padding: 0.25ex 0.5em; }
+    #cbho-name { width: 30em; }
+    #cbho-field-order { width: 30em; }
+    #cbho-field-order-space > div { float: left; padding-right: 1em; }
+    #cbho-needs-saved { color: #c00; font-weight: bold; }
+    option.post-rtime, option.post-rtime:focus {
+        font-style: italic; color: #999;
+    }
+    .body-part { margin: 1ex 0; }
+    .clear-both { clear: both; }
+    .show-access-key { font-weight: bold; border-bottom: 1px dashed black; }
+</style>
+<h1>[% l('Best-Hold Selection Sort Order') %]</h1>
+
+<!-- Hidden after JS load. Prevents early clicks from breaking anything. -->
+<div id="cbho-loading">
+    <img src="[% ctx.media_prefix %]/opac/images/progressbar_green-old.gif"
+        alt="[% l('Interface loading') %]" />
+    <!-- <audio src="knight rider theme ;)" /> -->
+</div>
+
+<div id="cbho-main-body" class="hidden"><!-- main body -->
+    <div class="body-part">
+        <span dojoType="dijit.form.Button" onClick="module.new_cbho()">[% l('Create New') %]</span>
+        [% l('or') %]
+        <span dojoType="dijit.form.Button" onClick="module.edit_cbho()">[% l('Edit Existing') %]</span>
+    </div>
+
+    <div class="body-part hidden" id="cbho-edit-space"><!-- editing space -->
+        <p>
+            <span id="cbho-editing"></span>
+            <span id="cbho-needs-saved">[% l('You have unsaved changes.') %]</span>
+        </p>
+        <div id="cbho-name-edit-space">
+            <label for="cbho-name">[% l('Name:') %]</label>
+            <input id="cbho-name" type="text" onchange="module.editor_changed(true);" />
+        </div>
+        <div id="cbho-field-order-space" class="body-part">
+            <div>
+                <label for="cbho-field-order">[% l('Order:') %]</label>
+            </div>
+            <div>
+                <select id="cbho-field-order" size="10"> </select>
+            </div>
+            <div>
+                <input type="button"
+                    onclick="module.editor_move(-1); return false;"
+                    accesskey="[% l('k') %]"
+                    value="&uarr; [% l('Move Up') %]" />
+                <span class="show-access-key">[% l('k') %]</span>
+                <br />
+                <input type="button"
+                    onclick="module.editor_move(1); return false;"
+                    accesskey="[% l('j') %]"
+                    value="&darr; [% l('Move Down') %]" />
+                <span class="show-access-key">[% l('j') %]</span>
+            </div>
+        </div>
+
+        <div class="clear-both"></div>
+
+        <div class="body-part"><!-- save changes -->
+            <p><em>[% l('Because rtime, a high-precision timestamp, is ' _
+                'essentially unique among holds, ' _
+                'no fields arranged after rtime really have any effect in ' _
+                'determining best-hold selection.') %]</em></p>
+
+            <p>[% l('To choose which Best-Hold Selection Sort Order will be ' _
+                'used by Evergreen at copy capture time, see the Library ' _
+                'Settings interface.') %]</p>
+
+            <button id="cbho-save-changes"
+                onclick="module.editor_save(); return false" disabled="disabled">
+                [% l('Save Changes') %]
+            </button>
+        </div><!-- save changes -->
+    </div><!-- editing space -->
+
+</div><!-- main body -->
+
+<div class="hidden">
+    <div dojoType="openils.widget.ProgressDialog" id="progress-dialog"></div>
+    <div dojoType="dijit.Dialog" id="cbho-existing" title="[% l('Choose a best-hold order') %]">
+        <div class="body-part">
+            <label for="cbho-existing-selector">
+                [% l('Choose a best-hold order') %]
+            </label>
+            <span id="cbho-existing-selector"></span>
+        </div>
+        <div class="body-part">
+            <span dojoType="dijit.form.Button" type="submit"
+                id="cbho-existing-edit-go">
+                [% l('Edit') %]
+            </span>
+        </div>
+    </div>
+</div>
+
+<script type="text/javascript">
+    dojo.require("dijit.form.Button");
+    dojo.require("dijit.form.TextBox");
+    dojo.require("dijit.Dialog");
+    dojo.require("openils.widget.ProgressDialog");
+    dojo.require("openils.conify.BestHoldOrder");
+
+    var module;
+
+    openils.Util.addOnLoad(
+        function() {
+            module = openils.conify.BestHoldOrder;
+            module.init();
+        }
+    );
+</script>
+[% END %]
diff --git a/Open-ILS/web/js/dojo/openils/conify/BestHoldOrder.js b/Open-ILS/web/js/dojo/openils/conify/BestHoldOrder.js
new file mode 100644 (file)
index 0000000..52d2862
--- /dev/null
@@ -0,0 +1,307 @@
+if (!dojo._hasResource["openils.conify.BestHoldOrder"]) {
+    dojo.requireLocalization("openils.conify", "conify");
+
+    dojo._hasResource["openils.conify.BestHoldOrder"] = true;
+    dojo.provide("openils.conify.BestHoldOrder");
+    dojo.provide("openils.conify.SetOrderer");
+
+    dojo.require("dojo.string");
+    dojo.require("openils.Util");
+    dojo.require("openils.User");
+    dojo.require("openils.PermaCrud");
+    dojo.require("openils.widget.AutoFieldWidget");
+
+(function() {
+    var localeStrings =
+        dojo.i18n.getLocalization("openils.conify", "conify");
+
+    /* This helper module is OO. */
+    dojo.declare(
+        "openils.conify.SetOrderer", null, {
+            "constructor": function(select, field_map, format_string) {
+                this.select = select;   /* HTML <select> node */
+                this.field_map = field_map; /* object of id:label pairs */
+                this.format_string = format_string || "[${0}] ${1}";
+            },
+
+            "clear": function() {
+                dojo.forEach(
+                    this.select.options,
+                    dojo.hitch(
+                        this, function(o) { this.select.options.remove(o); }
+                    )
+                );
+            },
+
+            /* This trusts that what you are passing is actually a set (no
+             * repeats). */
+            "set": function(
+                set, pos_callback /* called for each set member's <option>
+                                     node now and at any position change */
+            ) {
+                this.clear();
+                this.pos_callback = pos_callback;
+                dojo.forEach(
+                    set, dojo.hitch(this, function(o, p) { this.add(o, p); })
+                );
+            },
+
+            "focus": function() {
+                this.select.focus();
+            },
+
+            /* For now this trusts that your item is in the field_map */
+            "add": function(item, position) {
+                var option = dojo.create(
+                    "option", {
+                        "value": item,
+                        "innerHTML": dojo.string.substitute(
+                            this.format_string, [item, this.field_map[item]]
+                        )
+                    }
+                );
+
+                this.select.options.add(option, null);
+                if (this.pos_callback)
+                    this.pos_callback(option, position);
+            },
+
+            /* Returns option values in order, as a set, assuming you didn't
+             * add dupes. */
+            "get": function() {
+                /* XXX Could probably use dojo.forEach() here, but don't have
+                 * time to check whether it's sure to preserve order
+                 * with pseudo-arrays or NodeLists or whatever this is. */
+                var list = [];
+                for (var i = 0; i < this.select.options.length; i++)
+                    list.push(this.select.options[i].value);
+
+                return list;
+            },
+
+            "move_selected": function(offset) {
+                var si = this.select.selectedIndex;
+                if (si < 0)
+                    return false;
+
+                var opt = this.select.options[si];
+                var len = this.select.options.length;
+                var newpos = si + offset;
+
+                if (newpos >= 0 && newpos < len) {
+                    var newopt = dojo.clone(opt);
+                    this.select.remove(si);
+                    this.select.add(newopt, newpos);
+
+                    if (this.pos_callback)
+                        for (var i = 0; i < len; i++)
+                            this.pos_callback(this.select.options[i], i);
+
+                    this.select.selectedIndex = newpos;
+                    return true;
+                } else {
+                    return false;
+                }
+            },
+        }
+    );
+
+    /* This module is *not* OO. */
+    dojo.declare("openils.conify.BestHoldOrder", null, {});
+
+    var module = openils.conify.BestHoldOrder;
+
+    /* We could get these from the IDL, but if we add more fields to that
+     * later, we have no particular mechanism for determining what is or
+     * isn't metadata. */
+    module.fields = ["pprox", "hprox", "aprox", "priority", "cut", "depth",
+        "htime", "rtime", "approx", "shtime"];
+
+    module.init = function() {
+        module.progress_dialog = dijit.byId("progress-dialog");
+        module.existing_dialog = dijit.byId("cbho-existing");
+
+        dojo.connect(
+            dijit.byId("cbho-existing-edit-go"),
+            "onClick",
+            null,
+            module.editor_load_selected_cbho
+        );
+
+        module.field_labels = {};
+        dojo.forEach(
+            module.fields, function(f) {
+                module.field_labels[f] = fieldmapper.IDL.fmclasses.cbho.
+                    field_map[f].label
+            }
+        );
+
+        module.set_orderer = new openils.conify.SetOrderer(
+            dojo.byId("cbho-field-order"),
+            module.field_labels,
+            localeStrings.CBHO_FIELD_DISPLAY
+        );
+
+        openils.Util.hide("cbho-loading");
+        openils.Util.show("cbho-main-body");
+    };
+
+    module.new_cbho = function() {
+        module.cbho = new fieldmapper.cbho();
+
+        module.editor_start();
+    };
+
+    module.edit_cbho = function() {
+        module.progress_dialog.show(true);
+
+        function proceed(w) {
+            module.edit_cbho_selector = w;
+            module.progress_dialog.hide();
+            module.existing_dialog.show();
+        };
+
+        if (module.edit_cbho_selector) {
+            proceed(module.edit_cbho_selector);
+        } else {
+            new openils.widget.AutoFieldWidget({
+                "fmClass": "cbho",
+                "selfReference": true,
+                "dijitArgs": {"required": true},
+                "parentNode": dojo.create(
+                    "span", null, dojo.byId("cbho-existing-selector")
+                )
+            }).build(proceed);
+        }
+    };
+
+    /* Causes next use of Edit Existing button to recreate, thereby picking
+     * up any new objects */
+    module.clear_cbho_selector = function() {
+        if (module.edit_cbho_selector) {
+            module.edit_cbho_selector.destroy();
+            module.edit_cbho_selector = null;
+        }
+    };
+
+    module.editor_load_selected_cbho = function() {
+        var id = module.edit_cbho_selector.attr("value");
+
+        if (id) {
+            module.cbho = (new openils.PermaCrud()).retrieve("cbho", id);
+            module.editor_start();
+        } else {
+            alert(localeStrings.CBHO_NO_LOAD);
+        }
+    };
+
+    module.editor_start = function() {
+        dojo.byId("cbho-editing").innerHTML = module.cbho.id() ?
+            dojo.string.substitute(
+                localeStrings.CBHO_EDITING_EXISTING,
+                [module.cbho.id(), module.cbho.name()]
+            ) :
+            localeStrings.CBHO_EDITING_NEW;
+
+        dojo.byId("cbho-name").value = module.cbho.name() || "";
+        module.editor_reset_order();
+
+        openils.Util.show("cbho-edit-space");
+        module.editor_changed(false);
+    };
+
+    /* Used to set all <option> nodes in the set_orderer to appear disabled if
+     * they now come after rtime. */
+    module.set_pos_callback = function(opt_node, pos) {
+        var method = module.rtime_reached ? "addClass" : "removeClass";
+        dojo[method](opt_node, "post-rtime");
+
+        if (opt_node.value == "rtime")
+            module.rtime_reached = true;
+    };
+
+    module.stored_cbho_field_order = function() {
+        var obj = module.cbho;
+
+        return module.fields.sort(
+            function(a, b) {
+                a = obj[a]();
+                var left = (a === null || typeof a == "undefined") ?
+                    999 : Number(a);
+
+                b = obj[b]();
+                var right = (b === null || typeof b == "undefined") ?
+                    999 : Number(b);
+
+                return left - right;
+            }
+        );
+    };
+
+    module.editor_reset_order = function() {
+        module.rtime_reached = false;
+        module.set_orderer.set(
+            module.stored_cbho_field_order(), module.set_pos_callback
+        );
+    };
+
+    module.editor_move = function(offset) {
+        module.rtime_reached = false;
+        if (module.set_orderer.move_selected(offset))
+            module.editor_changed(true);
+
+        /* Without this, focus is now on the up or down button, breaking
+         * the user's ability to select other rows with the arrow keys. */
+        module.set_orderer.focus();
+    };
+
+    module.editor_changed = function(changed) {
+        dojo.attr("cbho-save-changes", "disabled", !changed);
+        if (changed)
+            openils.Util.show("cbho-needs-saved", "inline");
+        else
+            openils.Util.hide("cbho-needs-saved");
+    };
+
+    module.editor_save = function() {
+        var name = dojo.byId("cbho-name").value;
+        if (!name || !name.length) {
+            alert(localeStrings.CBHO_NEEDS_NAME);
+            return false;
+        } else {
+            module.cbho.name(name);
+        }
+
+        module.progress_dialog.show(true);
+        var fields = module.set_orderer.get();
+        for (var i = 0; i < fields.length; i++)
+            module.cbho[fields[i]](i);
+
+        try {
+            var pcrud = new openils.PermaCrud();
+            pcrud[module.cbho.id() ? "update" : "create"](
+                module.cbho, {
+                    "oncomplete": function(r, list) {
+                        module.progress_dialog.hide();
+                        openils.Util.readResponse(r); /* alert on exceptions? */
+
+                        if (dojo.isArray(list) && list.length) {
+                            if (typeof list[0] == "object")
+                                module.cbho = list[0];
+
+                            module.clear_cbho_selector();
+                            module.editor_start();
+                        }
+
+                        pcrud.session.disconnect(); /* good hygiene? */
+                    }
+                }
+            );
+        } catch (E) {
+            alert(E);   /* better than doing nothing? */
+        }
+    };
+
+})();
+
+}
index c482bb6..608b998 100644 (file)
     "SURVEY_FOOT_LABEL": "Questions & Answers",
     "EVENT_DEF_LABEL" : "${0}: ${1}",
     "ACQ_DISTRIB_FORMULA_NAME_PROMPT" : "Enter new formula name",
-    "ACQ_DISTRIB_FORMULA_NAME_CLONE" : "${0} (Clone)"
+    "ACQ_DISTRIB_FORMULA_NAME_CLONE" : "${0} (Clone)",
+    "CBHO_EDITING_NEW": "You are editing a new best-hold order.",
+    "CBHO_EDITING_EXISTING": "You are editing best-hold order #${0}: ${1}.",
+    "CBHO_FIELD_DISPLAY": "[${0}] ${1}",
+    "CBHO_NO_LOAD": "Unable to load selected item.",
+    "CBHO_NEEDS_NAME": "You need to enter a name for the best-hold order."
 }
 
index f5e5a99..6738ffd 100644 (file)
@@ -4,6 +4,7 @@ if(!dojo._hasResource['openils.widget.AutoFieldWidget']) {
     dojo.require('openils.User');
     dojo.require('fieldmapper.IDL');
     dojo.require('openils.PermaCrud');
+    dojo.require('dojo.data.ItemFileReadStore');
        dojo.requireLocalization("openils.widget", "AutoFieldWidget");
 
     dojo.declare('openils.widget.AutoFieldWidget', null, {
index 109c9a6..877c926 100644 (file)
 <!ENTITY staff.main.menu.admin.server_admin.conify.usr_setting_type "User Setting Types">
 <!ENTITY staff.main.menu.admin.server_admin.conify.config_hard_due_date "Hard Due Date Changes">
 <!ENTITY staff.main.menu.admin.server_admin.conify.config_rule_circ_duration "Circulation Duration Rules">
+<!ENTITY staff.main.menu.admin.server_admin.conify.config_best_hold_order "Best-Hold Selection Sort Order">
 <!ENTITY staff.main.menu.admin.server_admin.conify.config_rule_recurring_fine "Circulation Recurring Fine Rules">
 <!ENTITY staff.main.menu.admin.server_admin.conify.config_rule_max_fine "Circulation Max Fine Rules">
 <!ENTITY staff.main.menu.admin.server_admin.conify.config_rule_age_hold_protect "Age Hold Protect Rules">
index 1bb5db9..2fb9268 100644 (file)
@@ -792,6 +792,10 @@ main.menu.prototype = {
                 ['oncommand'],
                 function(event) { open_eg_web_page('conify/global/config/circ_limit_group', null, event); }
             ],
+            'cmd_server_admin_config_best_hold_order' : [
+                ['oncommand'],
+                function(event) { open_eg_web_page('conify/global/config/best_hold_order', null, event); }
+            ],
             'cmd_server_admin_config_usr_activity_type' : [
                 ['oncommand'],
                 function(event) { open_eg_web_page('conify/global/config/usr_activity_type', null, event); }
index 347fac7..dcd0003 100644 (file)
     <command id="cmd_server_admin_actor_org_unit_custom_tree"
              perm="ADMIN_ORG_UNIT_CUSTOM_TREE VIEW_ORG_UNIT_CUSTOM_TREE"
              />
+    <command id="cmd_server_admin_config_best_hold_order"
+             perm="ADMIN_HOLD_CAPTURE_SORT"
+             />
 
     <command id="cmd_hotkeys_toggle" />
     <command id="cmd_hotkeys_set" />
                 <menuitem label="&staff.main.menu.admin.server_admin.conify.config_actor_sip_fields;" command="cmd_server_admin_config_actor_sip_fields"/>
                 <menuitem label="&staff.main.menu.admin.server_admin.conify.config_asset_sip_fields;" command="cmd_server_admin_config_asset_sip_fields"/>
                 <menuitem label="&staff.main.menu.admin.server_admin.conify.config_usr_activity_type;" command="cmd_server_admin_config_usr_activity_type"/>
+                <menuitem label="&staff.main.menu.admin.server_admin.conify.config_best_hold_order;" command="cmd_server_admin_config_best_hold_order"/>
                 <menuitem label="&staff.main.menu.admin.server_admin.conify.actor.org_unit_custom_tree;" command="cmd_server_admin_actor_org_unit_custom_tree"/>
                 <menu id="main.menu.admin.server.acq" label="&staff.main.menu.admin.server_admin.acq.label;" accesskey="&staff.main.menu.admin.server_admin.acq.accesskey;">
                     <menupopup id="main.menu.admin.server.acq.popup">
diff --git a/docs/RELEASE_NOTES_NEXT/custom-best-hold-selection.txt b/docs/RELEASE_NOTES_NEXT/custom-best-hold-selection.txt
new file mode 100644 (file)
index 0000000..4fb440a
--- /dev/null
@@ -0,0 +1,16 @@
+Custom best-hold selection sort order
+=====================================
+
+The ranking algorithm that chooses the best hold to target a copy in 
+hand at a capture time used to be fairly simple.  It had two modes, FIFO
+and not-FIFO, and that was it.
+
+This change allows full configuration of that algorithm.  In other 
+words, when the system captures a copy and sets out to evaluate what
+hold, if any, that copy might best fulfull, site staff of sufficient
+permission level are now empowered to choose exactly which comparisons
+the systems makes in what order.  This gives said staff much greater
+flexibililty than they have today over holds policy.
+
+For more information, see the included tech spec documents.
+
diff --git a/docs/TechRef/Circ/custom-best-hold-selection.txt b/docs/TechRef/Circ/custom-best-hold-selection.txt
new file mode 100644 (file)
index 0000000..21e110e
--- /dev/null
@@ -0,0 +1,247 @@
+Custom Best-Hold Selection
+==========================
+
+Background
+----------
+
+In the Evergreen ILS, during opportunistic capture (which occurs at copy
+checkin time), the copy being checked in is evaluated by the system for its
+fitness to fulfill outstanding holds.  When the copy might fulfill more than
+one hold, a set of 'determinants' are used to rank the possible holds that
+might be fulfilled, so that the best hold may be chosen.
+
+Evergreen currently uses one of two possible sets of 'determinants' to rank
+the holds that a given copy might fulfill.  An org-unit setting determines
+which set of 'determinants' is used.
+
+We will call these sets the "best-hold selection sort orders".  The best-hold
+selection sort orders available for use at hold capture time are:
+
+Traditional
+~~~~~~~~~~~
+  . 'pprox' - Proximity of capturing location to pickup library
+  . 'priority' - Group hold priority
+  . 'cut' - Hold cut-in-line
+  . 'depth' - Hold selection depth (deeper/narrower first)
+  . 'rtime' - Hold request time
+
+FIFO
+~~~~
+  . 'priority' - Group hold priority
+  . 'cut' - Hold cut-in-line
+  . 'rtime' - Hold request time
+  . 'depth' - Hold selection depth (deeper/narrower first)
+  . 'pprox' - Proximity of capturing location to pickup library
+
+In either of these scenarios, a case could be made for changing the order of
+several fields. However, the use of these is currently controlled only by a
+single org-unit setting to turn on or off FIFO (if FIFO is "off," the
+Traditional set is used).
+
+Adding more org-unit settings to control yet more hard-coded orderings is a
+path to madness, and therefore we should support custom field ordering for
+best-hold selection.
+
+Proposal
+--------
+
+To that end, we propose a new table to define field importance, and a new org-
+unit setting to replace "FIFO Holds" and select the appropriate definition for
+the capturing location. The UI for creating or editing hold order definitions
+should consist of a list for ordering the options, controlled by up-and-down
+buttons both clickable and accessible by keyboard.  There will also be a field
+for naming the definition and a save button.
+
+This org-unit setting will be retrieved at capture time, instead of the FIFO
+setting, and inspected by open-ils.storage.action.hold_request.nearest_hold.
+If no value is set, the equivalent of the "traditional" order will be used.
+
+An upgrade script will change all FIFO settings to version of the new setting
+which points to the system-supplied definition that implements FIFO as it
+stands today, thus avoiding functional changes and configuration problems.
+
+Design
+------
+
+Database Sketch
+~~~~~~~~~~~~~~~
+
+The 'config.best_hold_order' database table will have two metadata columns
+and eight data columns.
+
+Each of the eight data columns corresponds to a similarly named column used for
+ranking in the best-hold selection process (i.e., the 'determinants').  In a
+given row, the value of each of these columns corresponds to its relative
+priority in the ranking decision (lowest value representing the highest
+priority).
+
+Data columns with a null value have the effect of omitting the corresponding
+determinant in the ORDER BY clause for best-hold selection when the given
+best-hold selector order set is in play.
+
+One of the 'determinants', *aprox*, depends on the Calculated Proximity
+Adjustment enchancement (documented elsewhere).
+
+The 'determinant' *rtime*, which in practice is virtually unique among the
+set of all holds at a site, will always terminate the list of determinants
+used in constructing the ORDER BY clause whenever it appears.  In other words,
+because *rtime* will never tie anyway, no more comparisons after rtime have
+any meaning.
+
+The default best-hold order sets sketched here are subject to refinement and
+are not guaranteed to represent the final product.
+
+[source,sql]
+------------------------------------------------------------------------------
+
+CREATE TABLE config.best_hold_order(
+    id          SERIAL      PRIMARY KEY,    -- (metadata)
+    name        TEXT        UNIQUE,   -- i18n (metadata)
+    pprox       INT, -- copy capture <-> pickup lib prox
+    hprox       INT, -- copy circ lib <-> request lib prox
+    aprox       INT, -- copy circ lib <-> pickup lib ADJUSTED prox on ahcm
+    priority    INT, -- group hold priority
+    cut         INT, -- cut-in-line
+    depth       INT, -- selection depth
+    htime       INT, -- time since last home-lib circ exceeds org-unit setting
+    rtime       INT  -- request time
+);
+
+-- At least one of these columns must contain a non-null value
+ALTER TABLE config.best_hold_order ADD CHECK ((
+    pprox IS NOT NULL OR
+    hprox IS NOT NULL OR
+    aprox IS NOT NULL OR
+    priority IS NOT NULL OR
+    cut IS NOT NULL OR
+    depth IS NOT NULL OR
+    htime IS NOT NULL OR
+    rtime IS NOT NULL
+));
+
+INSERT INTO config.best_hold_order (
+    name,
+    pprox, aprox, priority, cut, depth, rtime, htime, hprox
+) VALUES (
+    'Traditional',
+    1, 2, 3, 4, 5, 6, 7, 8
+);
+
+INSERT INTO config.best_hold_order (
+    name,
+    hprox, pprox, aprox, priority, cut, depth, rtime, htime
+) VALUES (
+    'Traditional with Holds-always-go-to-home-patrons',
+    1, 2, 3, 4, 5, 6, 7, 8
+);
+
+INSERT INTO config.best_hold_order (
+    name,
+    htime, hprox, pprox, aprox, priority, cut, depth, rtime
+) VALUES (
+    'Traditional with Holds-go-home',
+    1, 2, 3, 4, 5, 6, 7, 8
+);
+
+INSERT INTO config.best_hold_order (
+    name,
+    priority, cut, rtime, depth, pprox, hprox, aprox, htime
+) VALUES (
+    'FIFO',
+    1, 2, 3, 4, 5, 6, 7, 8
+);
+
+INSERT INTO config.best_hold_order (
+    name,
+    hprox, priority, cut, rtime, depth, pprox, aprox, htime
+) VALUES (
+    'FIFO with Holds-always-go-to-home-patrons',
+    1, 2, 3, 4, 5, 6, 7, 8
+);
+
+INSERT INTO config.best_hold_order (
+    name,
+    htime, priority, cut, rtime, depth, pprox, aprox, hprox
+) VALUES (
+    'FIFO with Holds-go-home',
+    1, 2, 3, 4, 5, 6, 7, 8
+);
+
+INSERT INTO config.org_unit_setting_type (
+    name, label, description, datatype, fm_class, update_perm
+) VALUES (
+    'circ.hold_capture_order',
+    'Best-hold selection precedence',
+    'Defines the sort order of holds when selecting a hold to fill using a given copy at capture time',
+    'link',
+    'cbho',
+    'ADMIN_HOLD_CAPTURE_SORT'
+);
+
+INSERT INTO config.org_unit_setting_type (
+    name, label, description, datatype, update_perm
+) VALUES (
+    'circ.hold_go_home_interval',
+    'Max foreign-circulation time',
+    'Time a copy can spend circulating away from its circ lib before returning there to fill a hold (if one exists there)',
+    'interval',
+    'ADMIN_HOLD_CAPTURE_SORT'
+);
+
+INSERT INTO actor.org_unit_setting (
+    org_unit, name, value
+) VALUES (
+    1,
+    'circ.hold_go_home_interval',
+    '6 months'
+);
+
+UPDATE actor.org_unit_setting SET
+    name = 'circ.hold_capture_order',
+    value = (SELECT id FROM config.hold_capture_sort WHERE name = 'FIFO')
+WHERE
+    name = 'circ.holds_fifo';
+------------------------------------------------------------------------------
+
+
+When constructing ORDER BY clauses, the *htime* determinant will be
+represented by a more complex expression than the other determinants.  The
+likely form of this will be as follows:
+
+[source,sql]
+-----------------------------------------------
+CASE WHEN
+    ['value of org setting circ.hold_go_home_interval'] <
+        NOW() - ['timestamp of last circulation at copy circ lib']
+    THEN hprox      -- sic
+    ELSE 999
+END
+
+-----------------------------------------------
+
+Middle Layer
+~~~~~~~~~~~~
+
+The 'open-ils.storage.action.hold_request.nearest_hold' method issues a query
+with an ORDER BY clause.
+
+This clause, previously selected from two hard-coded choices based on a
+boolean value indicating use- or don't-use-FIFO, will now be
+dynamically prepared based on the order specified in the
+'circ.hold_capture_order' org-unit setting.
+
+User Interface
+~~~~~~~~~~~~~~
+
+A user interface will allow the creation of new best-hold orders and the
+editing of existing ones, given sufficient user permission.
+
+The name field (metadata) will be editable with a free-form text widget, and
+the remaining (data) fields will be represented by objects that the user
+manipulates via clickable buttons (also keyboard accessible) to indicate order.
+
+////
+vim: ft=asciidoc
+////
+
+