</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" />
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) {
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;
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)
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
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
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;
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,
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;
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)
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)
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;
( 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'))
;
('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"'
+);
+
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;
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)
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)
--- /dev/null
+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;
--- /dev/null
+[% 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="↑ [% 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="↓ [% 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 %]
--- /dev/null
+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? */
+ }
+ };
+
+})();
+
+}
"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."
}
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, {
<!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">
['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); }
<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">
--- /dev/null
+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.
+
--- /dev/null
+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
+////
+
+